Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Exceptions vs. non-local control constructs vs. unwinding #142

Closed
RossTate opened this issue Oct 15, 2020 · 83 comments
Closed

Exceptions vs. non-local control constructs vs. unwinding #142

RossTate opened this issue Oct 15, 2020 · 83 comments

Comments

@RossTate
Copy link
Contributor

Exceptions, non-local control constructs, and unwinding are all related but different entities. It is important to understand the distinction between these concepts, and to see that distinction consider the following C++ program:

#include <csetjmp>
int main() {
    jmp_buf env;
    int val = setjmp(env); // val is 0 on first call
    if (val==0) {
        try {
            longjmp(env, 5); // makes setjmp return 5 "instead"
        } catch (...) {
            return 1; // never executed
        }
    } else {
        return 2; // executed
    }
}

According to the C++ spec, this program returns 2, not 1. This is because although longjmp is a non-local control construct, it is not an exception. (And to clarify, the behavior of this program does not depend on how catch (...) is specified to interact with foreign exceptions because longjmp is not considered a foreign exception either.) So exceptions are distinct (but closely related to) from non-local control constructs.

I was careful to make sure this example involves no unwinding. How longjmp interacts with unwinding is not defined by the C++ spec, intentionally deferring it to the platform. (Similarly, the C++ spec does not specify how unwinding interacts with uncaught exceptions, intentionally deferring it to the platform because the behavior of single-phase vs. two-phase EH implementations differ here.) The GNU compilers do not have longjmp cause unwinding, whereas Visual Studio does by default (though you can turn it off). (Visual Studio also lets you configure whether foreign/system exceptions should cause unwinding and discusses why you would want unwinding for some circumstances and why you would not want unwinding for other circumstances.) So non-local control constructs are distinct (but closely related to) from unwinding. (To clarify, I am not advocating to add non-unwinding non-local control constructs in this proposal.)


Okay, so why do these distinctions matter? Well, just as many languages compiling to C use setjmp/longjmp to implement their own non-local control constructs (as it is the only non-local option), many languages compiling to WebAssembly will use throw/catch to implement their own non-local control constructs (again, as it is the only non-local option). We should anticipate this. Similarly, WebAssembly eventually add other non-local control constructs. We should leave room for this. unwind does both by providing a way to specifying unwinding code with no assumptions about why the stack is being unwound. It is closely related to unwinding clauses in other systems—fault, (part of) finally, unwind-protect, and (part of) dynamic-wind—all of which similarly specify/treat an unwinder as a block/function of type [] -> [].

Now, one particular non-local control construct that will need to be emulated with throw/catch is setjmp/longjmp. Because the spec gives us the option to have longjmp cause unwinding, this is mostly straightforward to do using some $longjmp exception event. But there's a problem if one translates catch (...) to catch_all: the catch_all will mistake the $longjmp event for an exception. That would make our example C++ program above incorrectly return 1. And while yes, you could hack the compilation of catch (...) to exclude the $longjmp event, that only excludes your own long jumps, failing to exclude other C/C++-as-wasm program's long jumps as well as other languages' non-local control constructs, which catch (...) seems to specifically not be intended to catch.

Hopefully this illustrates part of the rationale behind unwind, and hopefully this more concrete example better illustrates the concern about compositionality of catch_all that I had expressed more abstractly in #128.

@rossberg
Copy link
Member

I draw the exact opposite conclusion: given the wealth of arcane control features and semantics across languages, it would be a losing game to try supporting them all natively. Such an additive approach would produce a monster of a language. Instead, we need to enable all their implementations by a combination of a general and composable base mechanism for control transfer and the ability to code up as much of the specifics as possible in user space.

As for cross-language control, I had assumed that their is agreement that it falls under the same "no seamless interop" caveat that Wasm already applies for cross-language data. All an exception/control mechanism for Wasm can hope to achieve is enabling interop. It cannot magically provide it, as there is no universal solution. Multiple interacting languages will either have to agree on a common control ABI, or avoid cross-language control transfer. Maybe interface types can one day be enriched with a control dimension.

@RossTate
Copy link
Contributor Author

RossTate commented Oct 15, 2020

given the wealth of arcane control features and semantics across languages, it would be a losing game to try supporting them all natively

This is a strawman argument. Leaving room to add more control constructs is not the same as adding all the control constructs.

It cannot magically provide it, as there is no universal solution.

This, too, is a misrepresentation of what I was advocating for. I was advocating for a solution that avoids unintentional interference of control constructs. I illustrated that C++ avoids such unintentional interference, as do many other languages.

On that note, I pointed out that unwind-like constructs are widespread, but you have yet to point me to a reference for catch_all/rethrow.

@aheejin
Copy link
Member

aheejin commented Oct 15, 2020

@RossTate

Whether we should compile catch (...) as catch_all or just catch $cpp_exception_tag is a matter of the tool convention, not the spec. Tool conventions are discussed in the tool-convention repo. (By the way the EH scheme there has not been updated for a long time, so it's not up-to-date)

I'm not sure why is C++'s catch (...) compilation scheme related to necessity for unwind.

@RossTate
Copy link
Contributor Author

Whether we should compile catch (...) as catch_all or just catch $cpp_exception_tag is a matter of the tool convention, not the spec.

The example above shows that catch_all would be a semantically incorrect way to compile catch (...).

I'm not sure why is C++'s catch (...) compilation scheme related to necessity for unwind.

I'm using the fact that catch (...) is not supposed to catch longjmp to concretize that there are non-local control constructs that are not exceptions. For these, it does not make sense to catch_all and rethrow them—many of them already have a predetermined location in mind and are just piggybacking throw and catch to get to that location. (Hence you don't see this catch_all/rethrow pattern in other systems with non-exceptional non-local control.) Programs just need the portion of the stack that was skipped over by the non-local control transfer to be unwound, and unwind provides the means for precisely that functionality.

@aheejin
Copy link
Member

aheejin commented Oct 16, 2020

I'm using the fact that catch (...) is not supposed to catch longjmp to concretize that there are non-local control constructs that are not exceptions.

As I said, we can compile catch (...) to catch $cpp_tag. Actually, we are doing it even now (using br_on_exn for the same effect). I'm not sure why you are assuming otherwise.

Programs just need the portion of the stack that was skipped over by the non-local control transfer to be unwound, and unwind provides the means for precisely that functionality.

Still don't understand what this has to do with unwind.
First, we haven't defined catch_all should catch non-exception control flows yet in this MVP proposal. But it is assumed that unwind runs for any kinds of control flow construct, right? And longjmp does not cause destructors to run. So according to your argument, implementing longjmp with throw and using unwind with it is actually a problem, not a solution.

@RossTate
Copy link
Contributor Author

RossTate commented Oct 16, 2020

I'm not sure why you are assuming otherwise.

Because you said in our meetings that the reason you wanted catch_all was to support catch (...), and multiple discussions reference this expectation. If that is not the case, then what is your intended purpose for catch_all?

And longjmp does not cause destructors to run.

That spec is poorly worded, as the second sentence there contradicts the first. The second sentence states that the behavior is undefined if replacing the given setjmp/longjmp with a catch/throw would cause non-trivial destructors to fire. This spec is more clear about the undefined behavior, and this spec goes further to clarify that the choice tends to be compiler-specific (and discusses flags for configuring this behavior).

But it is assumed that unwind runs for any kinds of control flow construct, right?

No, because br and the like do not cause unwinding. And if you wanted a version of longjmp that does not unwind, then that would not be another counterexample. But at present, all local control flow does not unwind whereas all non-local control flow does unwind. unwind makes it easy to maintain that property in future extensions if we want, and I don't think there's any reason to consider breaking that property in this MVP proposal.

First, we haven't defined catch_all should catch non-exception control flows yet in this MVP proposal.

What I was trying to point out is that in the MVP all non-local control flow will likely be encoded with catch/throw for quite some time, which means that catch_all will effectively catch all non-local control flow for quite some time.

@rossberg
Copy link
Member

rossberg commented Oct 19, 2020

given the wealth of arcane control features and semantics across languages, it would be a losing game to try supporting them all natively

This is a strawman argument. Leaving room to add more control constructs is not the same as adding all the control constructs.

It's not the same yet, but it is the end of the path that such an approach inevitably puts you on. (Either that, or you intend to carve language privilege into stone forever.)

It cannot magically provide it, as there is no universal solution.

This, too, is a misrepresentation of what I was advocating for. I was advocating for a solution that avoids unintentional interference of control constructs. I illustrated that C++ avoids such unintentional interference, as do many other languages.

You can program up this solution by using two different Wasm-level exception tags in the C++ runtime, as @aheejin said. I don't see how unwind helps here, unless you are suggesting that we should also make longjmp a Wasm primitive? For that, see above.

On that note, I pointed out that unwind-like constructs are widespread, but you have yet to point me to a reference for catch_all/rethrow.

Catch-all/rethrow is what e.g. JS engines do at the lower level to actually implement something like finally/unwind. Both features are also present in popular languages like C++ or C#.

@RossTate
Copy link
Contributor Author

Catch-all/rethrow is what e.g. JS engines do at the lower level to actually implement something like finally/unwind. Both features are also present in popular languages like C++ or C#.

Engines are free to implement these constructs however they want. JS engines always "rethrow" an unwinding exception in the same dynamic context it was intercepted in. unwind ensures precisely that. Neither C++ throw; nor C# throw; compile to rethrow. Many implementations of C++ do not compile destructors to catch_all/rethrow both because of longjmp and to interact well with Win32 SEH. The .NET CIL has a rethrow instruction (which neither C# throw; nor finally compile to), but it explicitly says that correct CIL does not use rethrow inside any exception handlers. That is, correct CIL requires the exception to be rethrown in the same dynamic context it was caught in, which is what unwind ensures but catch_all/rethrow does not.

So even these systems seem to be restricted to unwind, which again is in line with other systems that have more advanced notions of control and have investigated compositionality with respect to control more thoroughly.

@RossTate
Copy link
Contributor Author

@aheejin Given your clarification that catch_all is not planned to be used for catch (...) (avoiding the issue with interacting longjmp), can you clarify what catch_all is planned to be used for?

@rossberg
Copy link
Member

rossberg commented Nov 3, 2020

Engines are free to implement these constructs however they want. JS engines always "rethrow" an unwinding exception in the same dynamic context it was intercepted in.

Engines do things like compiling finally by having a shared piece of code that is entered with a flag marking its continuation mode. Compilers targeting the EH might want to use a similar technique, but that does not seem to be possible anymore under the current proposal (but was before!).

I don't understand how the current proposal allows a compiler to implement finally at all in a way that consistently uses unwind without requiring to duplicate the entire, arbitrarily large handler code (which would lead to a code size blow-up exponential in the nesting depth of finally handlers). You cannot easily factor it out into a function like in a high-level language, since the handler almost always needs access to locals.

@RossTate
Copy link
Contributor Author

RossTate commented Nov 3, 2020

I don't understand how the current proposal allows a compiler to implement finally at all in a way that consistently uses unwind without requiring to duplicate the entire, arbitrarily large handler code (which would lead to a code size blow-up exponential in the nesting depth of finally handlers).

It doesn't allow this.

@aheejin
Copy link
Member

aheejin commented Nov 4, 2020

@rossberg

Engines do things like compiling finally by having a shared piece of code that is entered with a flag marking its continuation mode. Compilers targeting the EH might want to use a similar technique, but that does not seem to be possible anymore under the current proposal (but was before!).

How did the previous proposal allow this? You mean the one with exnref, because we were able to factor out the code?

I don't understand how the current proposal allows a compiler to implement finally at all in a way that consistently uses unwind without requiring to duplicate the entire, arbitrarily large handler code (which would lead to a code size blow-up exponential in the nesting depth of finally handlers). You cannot easily factor it out into a function like in a high-level language, since the handler almost always needs access to locals.

This doesn't currently allow this. What do you suggest as an alternative? Re-add exnref? Or add finally?

I'm not against to adding finally as a separate primitive. It was discussed in #11 and people (including you) said we didn't need it as a primitive because it could be compiled away.

@tlively
Copy link
Member

tlively commented Nov 4, 2020

It would be good not to get too caught up in the problem of supporting finally without code duplication. The JVM also requires code duplication to implement finally and apparently it hasn't been a problem in practice.

In more detail, the JVM specification documents a compilation scheme for finally that actually does deduplicate code using the jsr instruction, which essentially calls a block of code in the current function as if it were a nested function. However, the docs for jsr note that it hasn't been used by Oracle's Java compiler since Java SE 6. In other words, Java has been duplicating the handler code for finallys with no problems in practice since the end of 2006.

@aheejin
Copy link
Member

aheejin commented Nov 4, 2020

@RossTate

I'm using the fact that catch (...) is not supposed to catch longjmp to concretize that there are non-local control constructs that are not exceptions. For these, it does not make sense to catch_all and rethrow them—many of them already have a predetermined location in mind and are just piggybacking throw and catch to get to that location. (Hence you don't see this catch_all/rethrow pattern in other systems with non-exceptional non-local control.)

I don't understand why catch_all and rethrow will get in the way of non-exceptional non-local control flow, such as longjmp. You said it has a predetermined location, which is true, but that's something our C++ toolchain has to ensure that it matches. longjmp is not a wasm primitive, and this is a toolchian (or C compiler) correctness problem and not a spec problem.

@aheejin Given your clarification that catch_all is not planned to be used for catch (...) (avoiding the issue with interacting longjmp), can you clarify what catch_all is planned to be used for?

It was originally added to give wasm a way to do custom tasks for all non-local control flows. For example, wasm wants to print some message for all exceptional (or non-local) control flows.

@RossTate
Copy link
Contributor Author

RossTate commented Nov 4, 2020

It was originally added to give wasm a way to do custom tasks for all non-local control flows. For example, wasm wants to print some message for all exceptional (or non-local) control flows.

unwind seems to serve this purpose now. Can you clarify what catch_all is planned to be used for now?

@aheejin
Copy link
Member

aheejin commented Nov 4, 2020

unwind is intended for cleanups and not user handlers. In the current proposal unwind and catch_all are virtually the same, so you may use unwind for that purpose, but not in the follow-on proposal where unwind's semantics will be different from that of catch/catch_all.

@RossTate
Copy link
Contributor Author

RossTate commented Nov 4, 2020

Can you provide a concrete example of how catch_all is planned to be used in the current proposal?

@ioannad
Copy link
Collaborator

ioannad commented Nov 4, 2020

I think catch_all (which btw is also part of the already implemented 1st proposal) is intended for catching exceptions unknown to the module.

@RossTate
Copy link
Contributor Author

RossTate commented Nov 4, 2020

I understand the intent, but intents do not always match up with actual usage. I am looking for an actual usage that someone plans to generate to support some aspect of their language.

@aheejin
Copy link
Member

aheejin commented Nov 4, 2020

I already answered about the usage: wasm needs a way to handle unknown non-local control flows, such as printing a message. unwind is added as a preparation for the future 2PEH proposal, and we can't use it as user handlers there. C++ may not use catch_all for catch (...), but I don't understand why that is the reason we should remove the functionality.

@rossberg
Copy link
Member

rossberg commented Nov 5, 2020

@aheejin:

How did the previous proposal allow this? You mean the one with exnref, because we were able to factor out the code?

Yes. Here is the situation as I perceive it.

Before, we had a fairly canonical proposal with one universal try-construct that could express everything we needed it to express right now.

Now, we have an ad-hoc proposal that already has a zoo of 4 different try-constructs in order to accommodate some future use cases, but cannot even efficiently express everything we need right now. So we will likely end up with an even larger zoo.

In a low-level VM, I could see the need for perhaps two variants of try. But with 4+, I think we have taken a wrong turn. And to be clear, I don't think that's anybody's fault, and I especially sympathise with you trying to accommodate all the competing requests and making progress -- navigating the incompatible world views that we see on the CG these days has become almost impossible. We simply have run into a serious case of design-by-committee with too many hypotheticals.

It was discussed in #11 and people (including you) said we didn't need it as a primitive because it could be compiled away.

Yes, but that was when we still had a coherent design that allowed doing that. It would be great if it still did. That's the problem: this is no longer possible, at least not without unbounded code duplication.

What do you suggest as an alternative? Re-add exnref? Or add finally?

I'm not against to adding finally as a separate primitive.

I certainly would prefer to not add yet another try-construct. One suggestion I briefly made earlier was to generalise unwind to a finally that receives a Boolean parameter allowing to distinguish regular from exceptional entry.

But ultimately, that would merely be patching around the corners. The deeper problem is that we lack a principled overall design. Unfortunately, I don't have a constructive suggestion at this point other than going back to the drawing board (which is super frustrating, and I don't wanna be that guy).

@tlively, I'd argue that enforcing unbounded code duplication is poor design, the JVM notwithstanding -- which can hardly be seen as a pinnacle of good and forward-looking design. As a counter point, .NET, which had the luxury to learn from some of the JVM's mistakes, supports efficient finally (albeit by introducing its own additive approach).

@rossberg
Copy link
Member

rossberg commented Nov 5, 2020

@RossTate, I think the need for a catch-all is self-evident in a low-level VM, even if it's just to provide a means to implement robustness and diagnostics against uncaught exceptions on some level of a software stack.

@aheejin
Copy link
Member

aheejin commented Nov 5, 2020

@rossberg

When we were discussing #11, we didn't have exnref, so I think the necessity for code duplication when compiling away finally is not different from the current situation. Then we had the first version of the proposal, with try, catch $tag, and catch_all.

I share your concerns on proliferation of different try variants. I think we can remove unwind at least in the current version of the proposal; we can add it later if we need it in the 2PEH proposal later, if we get to make it. I understand why @RossTate wants to have it, but what I think is that it doesn't have to be added in this version of the proposal.

delegate is a tricky one to remove, because this was added in order to reduce code duplication, now that we don't have exnref.

If we remove unwind, we are basically back to the first version of the proposal, with only one addition of delegate, which I think is not too bad. (Generalizing unwind to finally with a boolean parameter also sounds OK to me.)

As you said, being able to unifying catch and catch_all in a single catch was a good part of the previous proposal, but it also added the dependency for the reference types proposal, so I think it's some trade-off. Also one of the reasons we added exnref was that we thought we could extend it to your typed continuation proposal, but your proposal didn't end up using it after all.

I appreciate your sympathy for this tumultuous CG process. As you've probably seen, the discussions in the repo in recent months have not been easy. I wish you weighed in more and shared your concerns you are currently sharing in the discussions before we passed the new proposal though, so that we were able to take into account your concerns more.

@RossTate
Copy link
Contributor Author

RossTate commented Nov 5, 2020

I think the need for a catch-all is self-evident in a low-level VM, even if it's just to provide a means to implement robustness and diagnostics against uncaught exceptions on some level of a software stack.

catch_all has no way to distinguish between caught and uncaught exceptions. There are also known applications (see #101) that need uncatchable non-local control transfer (that does not run unwinders). So while I agree with the high-level concern here, I do not believe catch_all properly addresses that concern.

It is also worth noting that even the few other systems with something like a catch_all also have something like unwind/finally and also specifically restrict rethrow (if they have it at all) to disallow it from throwing the caught exception in a different context.


From what I can tell, the prevailing misunderstanding seems to be that unwinding is done if and only if one is searching for an exception catch-point. Neither of these directions are true. Neither direction of this if and only if holds. To counter the "if" direction, we already have that a trap ignores unwinders, and #101 gives an application for an exception that ignores unwinders. But that direction is less pressing, so I only mention it to illustrate the disconnect between these constructs. More importantly, to counter the "only if" direction, the unwinding phase of two-phase exception handling has no search for an exception catch-point as that catch-point was already identified in the first phase. Another example is Common Lisp's return-from, which does not search the stack for a catch-point and only searches for unwinders.

As for finally, @aheejin requested that we not worry about directly supporting it now because it was not helpful for the pressing customer needs and can be indirectly supported through code duplication.

@rossberg
Copy link
Member

rossberg commented Nov 5, 2020

@aheejin:

When we were discussing #11, we didn't have exnref, so I think the necessity for code duplication when compiling away finally is not different from the current situation.

Ah, fair point. I probably did not notice this limitation at the time.

(For completeness, there is a way to compile finally without code duplication in the current proposal, by introducing an auxiliary one-off exception:

try A finally B  ~~>  (try (do (try A (throw $Aux) unwind B)) (catch $Aux))

where the type of $Aux matches the result type of the try block A. But obviously, this translation would be pretty expensive on the regular path, so isn't attractive.)

delegate is a tricky one to remove, because this was added in order to reduce code duplication, now that we don't have exnref.

Thinking out loud, there are a couple of options, but they all have a price:

  • Unify try-delegate and try-catch: instead of a catch block, each catch clause would just have a regular label to branch to, receiving the exception arguments. Then we don't need a separate delegate, because multiple handlers can target the same label. Downside: to support rethrow, you'd need some simple form of exnref.

  • Unify try-delegate and rethrow: under 1PEH, delegate is just a variation of rethrow. In principle it would be possible to generalise rethrow with a second immediate that determines where to rethrow. Then we can express try-delegate as try-catch_all-rethrow. But I'm not sure if that's the best way to go about things. At least it doesn't fit with the approach to 2PEH as currently imagined.

Maybe structured EH is just too high-level for Wasm. Not that I have a better suggestion...

As you said, being able to unifying catch and catch_all in a single catch was a good part of the previous proposal, but it also added the dependency for the reference types proposal, so I think it's some trade-off. Also one of the reasons we added exnref was that we thought we could extend it to your typed continuation proposal, but your proposal didn't end up using it after all.

FWIW, reference types as such should not be an issue, since they're at phase 4 and about to land Real Soon Now(tm). But in any case, catch vs catch-all is the thing I worry about least.

I appreciate your sympathy for this tumultuous CG process. As you've probably seen, the discussions in the repo in recent months have not been easy. I wish you weighed in more and shared your concerns you are currently sharing in the discussions before we passed the new proposal though, so that we were able to take into account your concerns more.

Yeah, I'm sorry, I think I tried. But simultaneously being stuck in multiple other CG discussions that are even more tumultuous and time-consuming doesn't help. :(

@rossberg
Copy link
Member

rossberg commented Nov 5, 2020

@RossTate:

catch_all has no way to distinguish between caught and uncaught exceptions.

In a scenario where it is used to gracefully handle uncaught exceptions in a given component, by construction, any exception that ever reaches it is otherwise uncaught, relative to that component.

There are also known applications (see #101) that need uncatchable non-local control transfer (that does not run unwinders).

That may be so, but has nothing to do with exceptions, uncaught or otherwise.

@RossTate
Copy link
Contributor Author

RossTate commented Nov 5, 2020

In what you described, more accurately you want to intercept all non-local control transfers out of the component. unwind does that.

@ioannad
Copy link
Collaborator

ioannad commented Nov 5, 2020

In what you described, more accurately you want to intercept all non-local control transfers out of the component. unwind does that.

How does it do that when it's equivalent to catch_all ... rethrow? Or are you referring to a different unwind?

And in general I'm confused about terms being used by different people with different meanings, in this proposal's issue discussions. It's not always clear from the context which meaning is meant.

@RossTate
Copy link
Contributor Author

RossTate commented Nov 5, 2020

@ioannad unwind gets executed for anything that unwinds the stack, including single-phase exceptions. For the described scenario, if one doesn't want an exception from the component to propagate further up the stack, one can br or return from within the unwind.

@aheejin
Copy link
Member

aheejin commented Nov 5, 2020

@RossTate

I don't think people, including me here, are confused about something. What I (and some of other people, I believe) was talking about was, unwind can have its uses, but in the MVP proposal it is the same as catch_all, so we can keep the MVP simple and add unwind later when necessary. That's all. Not having unwind now does not hinder compatibility with the future 2PEH, no matter what that will look like.

Also, when you argue to remove catch_all, your argument is "C++'s catch (...) will not use it".
But when you argue to keep unwind, you bring not only future 2PEH but also Common Lisp, which we don't even have a remote plan to support, or someone's question from February (#101), or other hypotheticals. #101 was just a passing question from someone, and we concluded it was not a problem or security threat as you suggested after all, and I woudn't want to relitigate all that again.

@fgmccabe
Copy link

fgmccabe commented Dec 1, 2020 via email

@RossTate
Copy link
Contributor Author

RossTate commented Dec 2, 2020

@dschuff Thanks for the update! The two instructions clearly overlap significantly in the current proposal, so if keeping both of them it would be useful to signal to generators how we expect them to differ over time (even though nothing should be set in stone at present) so that they know which instruction they should generate. My guess from the discussions above is that the expected distinction that would arise in the future but is unobservable now is the following:

  • unwind would be triggered by any control flow that prompts unwinding.
  • catch_all would be triggered by any (wasm?) exceptions.

This distinction would become observable by the introduction of any non-exceptional unwinding form of control flow, such as Common Lisp's return-from, which would trigger unwind but not catch_all. It would also become observable by the introduction of a non-unwinding form of exceptions, which would trigger catch_all but not unwind. It may be the case that neither of these happen, but the example is useful for, say, @phoe to know that Common Lisp's unwind-protect should probably be translated to unwind rather than catch_all. Does that signaling resonate with your expectations, @dschuff?


@phoe and @fgmccabe, I believe you two are attributing different meanings to the term "non-local control flow" in the above comment. I believe @fgmccabe heard multiple-stack notions of non-local control, whereas I believe @phoe meant to refer to specifically single-stack notions of non-local control.

@dschuff
Copy link
Member

dschuff commented Dec 2, 2020

@dschuff Thanks for the update! The two instructions clearly overlap significantly in the current proposal, so if keeping both of them it would be useful to signal to generators how we expect them to differ over time (even though nothing should be set in stone at present) so that they know which instruction they should generate. My guess from the discussions above is that the expected distinction that would arise in the future but is unobservable now is the following:

  • unwind would be triggered by any control flow that prompts unwinding.
  • catch_all would be triggered by any (wasm?) exceptions.

This distinction would become observable by the introduction of any non-exceptional unwinding form of control flow, such as Common Lisp's return-from, which would trigger unwind but not catch_all. It would also become observable by the introduction of a non-unwinding form of exceptions, which would trigger catch_all but not unwind. It may be the case that neither of these happen, but the example is useful for, say, @phoe to know that Common Lisp's unwind-protect should probably be translated to unwind rather than catch_all. Does that signaling resonate with your expectations, @dschuff?

Yeah I agree that it would be useful to somehow signal the expected use cases or potential future extensions somehow; maybe not in the official spec but perhaps in one of the kind of docs we currently have in the design repo. The terminology is a bit interesting/tricky (as your parenthetical hints at) because even in the current form wasm exceptions are still a much lower-level primitive than language-level exceptions (e.g. you could imagine implementations of language-level exceptions that only use unwind, as could eventually even be the case with C++).
But yes, I think that generally matches my understanding overall.

@RossTate
Copy link
Contributor Author

RossTate commented Dec 2, 2020

Sounds good. Thanks for the clarification!

@phoe
Copy link

phoe commented Dec 3, 2020

I would like to reinforce the sentiment above about signaling.

Currently, the following equivalence holds:

try ... unwind ... end === try ... catch_all ... rethrow 0 end

Because of this equivalence, someone might be tempted to rewrite one of those operators in terms of the other which will not introduce any short-term issues. Still, long-term issues will happen because this equivalence is accidental: the only kind of unwinds that are permitted by the current specification is via exceptions. When non-exceptional jumps are introduced in the future, this equivalence will break: a catch_all may not be triggered by a non-exceptional jump, while unwind will be triggered by it.

Therefore, I would like to request clarification in the spec by explicitly stating that:

  • unwind is meant with forward compatibility with non-exceptional non-local jumps in mind,
  • the semantics of unwind are different than those of catch_all even though the current revision of the specification does not provide any means of immediately perceiving this difference.

Rationale: Common Lisp makes heavy use of other forms of unwinding non-local control. With this proposal, I will need to encode those through exceptions, though at a substantial performance cost as the Clasp Common Lisp team has experienced (when implementing CL unwinds with C++ exceptions).

When direct support for other non-local control is added, I would like to be able to switch my implementation without breaking compatibility with other members of the ecosystem due to the switch no longer running their unwinders, should those unwinders be encoded using catch_all.

Having unwind would make that switch easier to implement, and encouraging others to use unwind to encode unwinders would help facilitate the transition to more efficient forms of non-local control.

@aheejin
Copy link
Member

aheejin commented Dec 3, 2020

@phoe

Therefore, I would like to request clarification in the spec by explicitly stating that:

  • unwind is meant with forward compatibility with non-exceptional non-local jumps in mind,
  • the semantics of unwind are different than those of catch_all even though the current revision of the specification does not provide any means of immediately perceiving this difference.

The current spec does not define or mention the concept of non-local jumps other than exceptions. Then how do we state in the spec that they are different even though there is no perceived differences?

Rationale: Common Lisp makes heavy use of other forms of unwinding non-local control. With this proposal, I will need to encode those through exceptions, though at a substantial performance cost as the Clasp Common Lisp team has experienced (when implementing CL unwinds with C++ exceptions).

While it is not impossible that we might have more means to unwind the stack later in future proposals, I'm not sure why you think throw is only for exceptions and meant to be expensive. Even though the name of the instruction is throw, wasm instructions are much lower level than C++ exceptions, and throw can be used to implement non-exceptional non-local jump features in other langauges.

@phoe
Copy link

phoe commented Dec 3, 2020

Then how do we state in the spec that they are different even though there is no perceived differences?

There exist jumps that are not exceptions and therefore should not trigger catch_all. The current WebAssembly specification does not support those, but it could acknowledge their existence and defer supporting them to a future proposal that defines them jumps in terms of unwind rather than catch_all.

Even though the name of the instruction is throw, wasm instructions are much lower level than C++ exceptions, and throw can be used to implement non-exceptional non-local jump features in other langauges.

Yes, they can, and it is possible to use semantics of throwing to implement other forms of non-local control. Still, it's essentialy using a higher-level operator (throwing/catching) to implement a lower-level one (stack unwinding), which is IMO doing it backwards. To keep this issue on-topic, I'll expand on this in a separate issue; please give me an hour or so to clean my sketch up and post it.

@aheejin
Copy link
Member

aheejin commented Dec 3, 2020

@phoe

Then how do we state in the spec that they are different even though there is no perceived differences?

There exist jumps that are not exceptions and therefore should not trigger catch_all. The current WebAssembly specification does not support those, but it could acknowledge their existence and defer supporting them to a future proposal that defines them jumps in terms of unwind rather than catch_all.

I'm not very sure what you mean by jumps. Do you mean branches? Or other kinds possible future non-local jumps other than exceptions? If you mean branches, unwind does not support unwinding with branches either.

There were discussions on how to support finally type of actions with respect to branches, but I don't think they are gonna be included in the MVP. We haven't decided whether we should support them or not yet in future proposals either yet. But while it is possible we can add some other constructs that support stack unwinding with respect to branches in future, I'm not sure if stating guesses about future hypothetical plans in the current spec is necessary.

Even though the name of the instruction is throw, wasm instructions are much lower level than C++ exceptions, and throw can be used to implement non-exceptional non-local jump features in other langauges.

Yes, they can, and it is possible to use semantics of throwing to implement other forms of non-local control. Still, it's essentialy using a higher-level operator (throwing/catching) to implement a lower-level one (stack unwinding), which is IMO doing it backwards. To keep this issue on-topic, I'll expand on this in a separate issue; please give me an hour or so to clean my sketch up and post it.

What I meant was, I'm not sure why you think the current 'throwing' is a different thing than your 'unwinding'.

@phoe
Copy link

phoe commented Dec 3, 2020

I'm not very sure what you mean by jumps. Do you mean (...) other kinds possible future non-local jumps other than exceptions?

Yes, I mean these.

I'm not sure if stating guesses about future hypothetical plans in the current spec is necessary.

My current goal is future-proofing the current definition of unwind to ensure that these future non-local jumps can be specified in the future. I am by no means an expert in formal specification work, but I currently worry that the meanings of unwind and catch_all become conflated at some point and these operators will be used interchangeably or - worse - one will be expressed in terms of one another, which will be a backwards compatibility problem.

What I meant was, I'm not sure why you think the current 'throwing' is a different thing than your 'unwinding'.

If I understand this proposal's throwing mechanism correctly, then throw is an operator that performs throwing from, beginning to unwind the stack and trigger all catch blocks along the way that must decide whether to rethrow or not.

The operator I am thinking of performs throwing to an established point on the stack, executing all "finally" blocks along the way before continuing from a predefined point in a function of some sort, without the need to construct any exception object, catch, or rethrow anything, because the point from which execution should continue is known ahead of time.

@aheejin
Copy link
Member

aheejin commented Dec 3, 2020

@phoe
throw does not trigger all catch blocks; it only triggers catches with a matching tag. So depending on which tag you use, you can adapt the catching behavior. For example, we use the same tag for all C++ exceptions. You can use a new tag for your new non-local features.

Also the concept of exception object is implicit, and the spec does not mandate creating a big object that contains all auxiliary info. The actual implementation can be as simple as a few values, depending on the need for a specific VM.

What I've been trying to convey is, it is hard to add hypothetical plans or guesses about the future proposals in the current proposal. I don't think it is necessary in the first place, and stating them requires defining what those future concepts are, which have not been defined well. Also I'm not sure what you mean by the spec all along; the explainer doc, which is written in plain English, only serves an introductory role for the formal spec. And the formal spec is mostly comprised of mathematical notations, within which we don't even have any means to include concepts on future plans. I also don't understand why not including future plans in the current spec will cause backward compatibility problems; those future concepts have not even been defined or used in the current spec, so not mentioning them cannot imply anything about their future usage.

@ioannad
Copy link
Collaborator

ioannad commented Dec 3, 2020

@aheejin, if I understand @phoe correctly, he is expressing reasons to worry about including try ... unwind ... end in this proposal, because in the future it might need to be specified in a way that is incompatible with how it is currently described.

Am I understanding you correctly, @phoe?

@ all: As I have said before, I don't think we should include unwind yet, as it is not serving any purpose currently. I think it would be more constructive to continue discussions on unwind in a different, more concrete, extension proposal instead of speculating about future plans in this MVP exception-handling.

Please don't get me wrong, I would love to see all sorts of advanced control flow in WebAssembly, I just don't think that this MVP exception handling proposal is the right place to debate these features. I think this proposal is to add very simple, throw-catch functionality that people want to use now, and that people have been implementing and discussing since 2017.

@aheejin
Copy link
Member

aheejin commented Dec 3, 2020

@ioannad

@aheejin, if I understand @phoe correctly, he is expressing reasons to worry about including try ... unwind ... end in this proposal, because in the future it might need to be specified in a way that is incompatible with how it is currently described.

I thought @phoe wanted to include unwind in the current proposal, but also wanted to include some additional text in the current spec about its intended usage in future. He wrote:

My current goal is future-proofing the current definition of unwind to ensure that these future non-local jumps can be specified in the future.

But @phoe, please correct me if I'm wrong.

As I have said before, I don't think we should include unwind yet, as it is not serving any purpose currently. I think it would be more constructive to continue discussions on unwind in a different, more concrete, extension proposal instead of speculating about future plans in this MVP exception-handling.
Please don't get me wrong, I would love to see all sorts of advanced control flow in WebAssembly, I just don't think that this MVP exception handling proposal is the right place to debate these features. I think this proposal is to add very simple, throw-catch functionality that people want to use now, and that people have been implementing and discussing since 2017.

As you can see from my previous comments in this post, I agree and I spent a long time here arguing to remove unwind in the MVP and add it later in the future extension proposal, but @RossTate didn't agree and we couldn't reach any consensus, which was why @dschuff proposed that we just leave the proposal as is, as a compromise.

@phoe
Copy link

phoe commented Dec 3, 2020

@aheejin OK - I think I understand a little bit more, thank you for the explanation.

If we encode the "unwind-to" behavior into the throw model, there is still a problem of knowing whether our "unwind-to" point is still on the stack. This happens e.g. when the function object that wants to jump to point A leaves the scope where point A is defined, and then is called and performs the throw.

Currently, it seems to me that the specified behavior will be to throw an exception that will not be caught (because the matching catching point is already off the call stack) and therefore the program crash. There are no means of knowing whether I can safely throw and in case I cannot safely throw I cannot choose to continue the execution of my program in a different way instead of throwing control into nowhere.

Also I'm not sure what you mean by the spec all along; the explainer doc, which is written in plain English, only serves an introductory role for the formal spec.

Yes, I mean the English explainer document.

I thought @phoe wanted to include unwind in the current proposal, but also wanted to include some additional text in the current spec about its intended usage in future.

Yes, this is correct.

As I have said before, I don't think we should include unwind yet, as it is not serving any purpose currently.

@ioannad The way I understand it, I think that the point raised by @RossTate somewhere earlier in the thread is that the current EH proposal is not about exception handling, it is also about destructors. Most importantly, adding catch and throw alone makes it nonetheless possible to implement unwinders (e.g. C++ destructors) in terms of catch_all + rethrow.

Once that is possible, then people will start doing it, because they're naturally impatient and want to get their C++ code to work in whatever way possible. This means that we'll soon have a de-facto implementation of unwind done via exception handling mechanisms, which will then make it hard to add a real, separate unwind instruction, because there will already be a de-facto unwind mechanism done via throw/catch that the "real" unwind instruction will need to compete with.

Removing unwind will open this aforementioned can of worms, because some people will do things to get destructors now even if it means sacrificing forward compatibility. This will cause problems once modules from different WebAssembly versions (and therefore these "de-facto" and "real" unwinds) will need to interoperate with one another.

I don't think this is solvable in the general case unless these old programs are recompiled to use the new, "real" unwind, which then explicitly breaks backwards compatibility. At that point, it might be tempting to put the blame on the impatient compiler authors, who implemented unwinds in term of operators that are not supposed to work as unwinds - which is exactly what I am attempting to solve with my proposal, just ahead of this whole mess!

I think that this danger is realistic because people are impatient, and that's why I am proposing this semantic addition to the current specification. If we explicitly specify that unwind is meant to be used for all types of unwinds and catch_all is meant to be used just for exception handling, then we do not leave unwind as it is right now without any semantic clarification. This might lead some people to express one of {catch_all, unwind} in terms of the other operator, due to their perceived but short-term equivalence.

I kinda wish that math and formalism alone was able to solve this problem, but at this moment this is a people issue, not a purely technical one. The purely technical specification will say what is there to be used, but it won't say how it should be used in a way that is compatible with future intents.

@ioannad @aheejin Does the above make sense?

@phoe
Copy link

phoe commented Dec 4, 2020

I think I've digested the issue well enough to try and summarize it.

By explicitly committing to a vocabulary that expresses exception handling within WebAssembly programs, we also implicitly commit to some vocabulary that expresses non-local control flow therewithin. That's because exception handling is a subset of non-local control flow in the general case, so we can't really decide on anything about EH without also deciding on something about non-local control flow.

In this concrete case, by explicitly specifying unwind in terms of catch_all + rethrow, we also implicitly specify that all future non-local control flow operators in WebAssembly must be expressed in terms of catch and throw; otherwise, unwind won't have a chance to execute their unwind forms.

(If we try to sidestep the problem by not specifying unwind now and removing it altogether from the current proposal, then users will happily construct it themselves from catch_all and rethrow; this effectively lands us in the same place as above.)

So the real issue that we've been discussing here, as I understand it, boils down to something that is not a problem of "just" exception handling, but a problem of which non-local control flow operators are going to be implementable in WebAssembly in the future.

If this proposal goes forwards in its current form, it implies that it must be possible and feasible to use throw, rethrow, catch, and catch_all as primitives for expressing everything that may cause stack unwinding within a single stack and is already out there in the non-local control flow dictionaries of programming languages that will target WebAssembly. This includes C/C++ longjmp, Common Lisp's non-local return-from/go along with their checks if the jump point is valid before the jump is performed, Erlang's processes and scheduling, Scheme's dynamic-wind, etc.. (1)

From my (relatively inexperienced) point of view, this is a somewhat big statement, especially since once it's made, it can't really be changed anymore.

Is WebAssembly ready to assert it?


(1): Note that all of this involves a single stack only; I'm not knowledgeable in the semantics of unwinding or exception handling across multiple stacks. (Thanks for pointing this out, @fgmccabe.)

@fgmccabe
Copy link

fgmccabe commented Dec 4, 2020

Thanks Michal,
I think that this expresses nicely some of the underlying concerns that some of us have.

@aheejin
Copy link
Member

aheejin commented Dec 4, 2020

@phoe

Once that is possible, then people will start doing it, because they're naturally impatient and want to get their C++ code to work in whatever way possible. This means that we'll soon have a de-facto implementation of unwind done via exception handling mechanisms, which will then make it hard to add a real, separate unwind instruction, because there will already be a de-facto unwind mechanism done via throw/catch that the "real" unwind instruction will need to compete with.

Removing unwind will open this aforementioned can of worms, because some people will do things to get destructors now even if it means sacrificing forward compatibility. This will cause problems once modules from different WebAssembly versions (and therefore these "de-facto" and "real" unwinds) will need to interoperate with one another.

I don't think this is solvable in the general case unless these old programs are recompiled to use the new, "real" unwind, which then explicitly breaks backwards compatibility. At that point, it might be tempting to put the blame on the impatient compiler authors, who implemented unwinds in term of operators that are not supposed to work as unwinds - which is exactly what I am attempting to solve with my proposal, just ahead of this whole mess!

I think that this danger is realistic because people are impatient, and that's why I am proposing this semantic addition to the current specification. If we explicitly specify that unwind is meant to be used for all types of unwinds and catch_all is meant to be used just for exception handling, then we do not leave unwind as it is right now without any semantic clarification. This might lead some people to express one of {catch_all, unwind} in terms of the other operator, due to their perceived but short-term equivalence.

I kinda wish that math and formalism alone was able to solve this problem, but at this moment this is a people issue, not a purely technical one. The purely technical specification will say what is there to be used, but it won't say how it should be used in a way that is compatible with future intents.

I don't think we can guide people to use a specific instruction for a specific intent other than their formally specified semantics. Also I don't think the future extension proposal (if it exists) should be, or can be binary compatible with the MVP proposal realistically. For example, if we implement 2PEH as a follow-on proposal, VMs have to implement it again and users most likely will have to recompile their source code to benefit from the new proposal's functionality.

And I don't think there are things like a real unwind or a not-real unwind. The MVP proposal should only say about what it defines. If the current proposal's unwind does a certain things, that's its real semantics. We don't have any say on how VMs use those instructions.

By explicitly committing to a vocabulary that expresses exception handling within WebAssembly programs, we also implicitly commit to some vocabulary that expresses non-local control flow therewithin. That's because exception handling is a subset of non-local control flow in the general case, so we can't really decide on anything about EH without also deciding on something about non-local control flow.

In this concrete case, by explicitly specifying unwind in terms of catch_all + rethrow, we also implicitly specify that all future non-local control flow operators in WebAssembly must be expressed in terms of catch and throw; otherwise, unwind won't have a chance to execute their unwind forms.

(If we try to sidestep the problem by not specifying unwind now and removing it altogether from the current proposal, then users will happily construct it themselves from catch_all and rethrow; this effectively lands us in the same place as above.)

So the real issue that we've been discussing here, as I understand it, boils down to something that is not a problem of "just" exception handling, but a problem of which non-local control flow operators are going to be implementable in WebAssembly in the future.

If this proposal goes forwards in its current form, it implies that it must be possible and feasible to use throw, rethrow, catch, and catch_all as primitives for expressing everything that may cause stack unwinding within a single stack and is already out there in the non-local control flow dictionaries of programming languages that will target WebAssembly. This includes C/C++ longjmp, Common Lisp's non-local return-from/go along with their checks if the jump point is valid before the jump is performed, Erlang's processes and scheduling, Scheme's dynamic-wind, etc.. (1)

From my (relatively inexperienced) point of view, this is a somewhat big statement, especially since once it's made, it can't really be changed anymore.

Is WebAssembly ready to assert it?

The MVP proposal does not assert anything on the instructions' usage. The spec only describes the instructions' behaviors. I personally don't know enough to determine whether all those different constructs from Common Lisp, Erlang, or Scheme you mentioned can be expressed with throw and catch. (And we don't have plans to support them in the foreseeable future.) But at the same time we don't (more precisely can't) prevent or instruct against people from using the instructions for non-EH non-local constructs as long as they work. For one thing I think we can implement longjmp with the current MVP spec.

As @ioannad also suggested, I would like to keep the MVP proposal as simple as possible, and I don't think we should include guesswork or conjecture on hypothetical future plans, or guidance on how users should or should not use those instructions. When there is an extension proposal with more functionality available, VMs can switch to use those proposals instead if they think the new functionalities are worth it.

@RossTate
Copy link
Contributor Author

RossTate commented Dec 4, 2020

Many language-design teams signal to their community about how new features are expected to be used and about how the language might change in the future. This signaling helps the community plan. To this end, the current WebAssembly core specification already has a few notes about how features are intended to be used and a number of notes about potential extensions. A note indicating that there might be extensions to WebAssembly that would cause the stack to be unwound for reasons besides exceptions and in so doing would trigger unwind but not catch_all would help generators plan so that, when such extensions arrive, they do not need to change their generation algorithms to use unwind rather than catch_all/rethrow.

@tlively
Copy link
Member

tlively commented Dec 4, 2020

If I understand @phoe's concerns correctly, one of them is essentially that there will need to be a change in compilation scheme when producers start using a future 2PEH proposal, whether or not they use unwind now. It would not be backwards compatible for catch_all and unwind to ever change their behavior with respect to events that can be expressed in the EH MVP, so producers that want to take advantage of future differences between catch_all and unwind would have to switch to using new event types that cannot be expressed in the EH MVP. As @aheejin mentioned, this would require users to recompile their code to take advantage of new features and be compatible with other new code. It is up to individual producers to decide whether requiring recompilation is acceptable. If it is not, their choices are to use the MVP EH features forever or to hold off on targeting Wasm until future proposals introduce the features they want.

(Alternatively, a producer could extract all of its non-exceptional event definitions and their uses into separately upgradable runtime modules, but I can't imagine that strategy being very appealing given that all catches would have to be set up by passing continuations to a runtime function.)

Another of @phoe's concerns is that it is not clear whether the current proposal provides everything a producer implementing non-local control flow would need. Semantically, it should be possible to implement any non-local control flow mechanism on top of the current EH proposal, assuming an execution environment in which all the WebAssembly producers can trust/coordinate with each other. Given the ability the initiate unwinding with throw and stop unwinding with catch, any other filtering, targeting, or other functionality can be implemented "in user space." The only remaining questions are of code size, performance, and correctness in the presence of untrusted code. Potential future proposals would be able to move particularly common mechanisms (e.g. 2PEH/2PU) from user space down into the engines to improve their performance and code size. That being said, there may be some small and clearly impactful additions we can make to the MVP to support non-exceptional control flow.

On the one hand, as @aheejin and @ioannad mentioned, the current proposal has really only tried to support the limited use case of C++-style exceptional control flow so far, so it could be considered a non-goal to explicitly support other forms of non-local control flow right now. On the other hand, it does seem likely that some producers will be eager to use the MVP functionality to implement non-exceptional control flow, so it might be nice to make sure their most pressing needs are met. We should definitively decide whether we want to consider non-exceptional use cases in the MVP as soon as possible (and explicitly write that decision down in the explainer) so that we can either defer further discussion about them to future proposals or continue discussing them now with a common understanding of our goals.

@aheejin
Copy link
Member

aheejin commented Dec 4, 2020

What I'm trying to convey is, it is not easy for the MVP spec to contain any recommendations or restrictions on usage without precisely defining them, but we don't have precise definition for them at this point, and I don't want to include any guesswork in the spec text.

We cannot say throw and catch should be only used for exceptions in various languages, because it is, first of all, not true. We don't intend the primitives to be exclusively used for exceptions. For one, we are planning to use them for longjmp.

But at the same time I can't say these primitives can support all non-local non-exceptional control flows for all existing languages out there, because I simply don't know and that's not this proposal's goal anyway. My guess is some of them can probably be supported with the current proposal and some of them can't. So it does not make sense to say throw and catch are intended to be used for all non-local control constructs, because that's not our intention either. First of all it depends on the kind of the nonlocal flow and the language, and it can be possibly supported by throw with a different tag, or we need to come up with a new extension proposal if there is a construct that's not easily supported with the MVP proposal within a language we plan to support.

So we can't even precisely define what kind of non-local non-exceptional control flow we talk about, and we neither encourage nor discourage uses of the current MVP with any non-local non-exceptional control flow. As I said, it depends. Some constructs can be supported and some not. I'm not sure what more we can include in the current MVP spec.

@RossTate
Copy link
Contributor Author

RossTate commented Dec 4, 2020

I don't think anyone was suggesting having notes making recommendations about throw/catch. The suggestions were about unwind and catch_all. These notes would simply say that, while it is unclear if WebAssembly will be extended with other (single-stack) notions of non-local control, should such an extension happen then here is how we suspect (though not guarantee) unwind and catch_all will come to differ. Similarly, a note could say that, should a WebAssembly program be executed by an embedder with its own ways to trigger unwinding, then the expectation is that unwind clauses would be triggered by that unwinding, and another note could say that, should a WebAssembly program be executed by an embedder with its own similar form of exceptions, then the expectation is that these exceptions would trigger catch_all clauses. None of this is enforceable at present due to the current constructs within wasm and the current embedders for wasm, but it's still useful to signal for reasons already outlined above.

@ioannad
Copy link
Collaborator

ioannad commented Dec 7, 2020

@tlively, I never said that:

@ioannad mentioned, the current proposal has really only tried to support the limited use case of C++-style exceptional control flow so far, so it could be considered a non-goal to explicitly support other forms of non-local control flow right now.

In particular, I never mentioned or indeed thought about C++ at all.

Throw and catch support for zero cost exceptions is an MVP in the sense that it is a subset of control flow constructs in many (most?) languages. We made the changes last September to ensure that this MVP is extendible with more advanced forms of control flow in the future.

I don't think there was ever agreement or indeed intent to add these other forms of non-local control flow right now. Did I miss this discussion?

@tlively
Copy link
Member

tlively commented Dec 7, 2020

@ioannad, I wrote that with this comment you made in mind:

I would love to see all sorts of advanced control flow in WebAssembly, I just don't think that this MVP exception handling proposal is the right place to debate these features. I think this proposal is to add very simple, throw-catch functionality that people want to use now, and that people have been implementing and discussing since 2017.

I intended my "C++-style exceptional control flow" to be exactly the "very simple, throw-catch functionality" you mentioned. In particular, I wrote "C++-style" because that's the specific language we have been implementing and discussing most, but I certainly expect other languages to use these mechanisms as well.

I don't think there was ever agreement or indeed intent to add these other forms of non-local control flow right now. Did I miss this discussion?

No, we have never said we want to add any other forms of non-local control flow to this proposal, and from what I can tell from this discussion, everyone is on the same page about that. However, there are still concerns about the performance and usability of the proposed throw-catch mechanisms with respect to non-exceptional control flow. We can either decide to address those concerns in a future proposal or decide to address them in this proposal (without adding new control flow primitives). It's not clear to me that we have consensus on whether these concerns are in-scope for this proposal, so I would like to hear what folks think about that choice.

@phoe
Copy link

phoe commented Dec 7, 2020

(If my questions have stirred up the pot a bit too much, then sorry about that.)

I have been thinking about this issue for a good part of the past few days and came to some more conclusions. This discussion seems so messy to me because we are right now discussing not two, but three interconnected issues:

  • ZCEH-style exceptions, which don't slow down the code along hot paths but have a high cost of unwinding and jumping,
  • SLJL-style exceptions, which have non-zero cost at runtime but low cost of unwinding and jumping,
  • unwinding the stack and implementing finally-style blocks.

The first two issues are fundamentally incompatible with one another, because one has blazing fast non-throw scenarios while the other has cheap unwinds. Expressing one in terms of the other is going to break these performance assumptions.

These two issues are also mostly orthogonal to the third, which is essential for implementing C++ destructors/Java's finally blocks/Lisp's unwind-protect. One troublesome part is that unwinds are also implementable in userspace on top of either of the two first proposals, which then causes the aforementioned issue of earlier, de-facto standards clashing with later, "actual" standards. Another, bigger issue, is that there is no exception-style-agnostic way to perform unwinds - they are encoded and performed differently in case of code that utilizes ZCEH-style and SJLJ-style exceptions. This means that it is not possible to design a single low-level stack unwinding mechanism that is going to work both for ZCEH-style and SLJL-style exceptions while respecting the performance requirements of these two approaches.

I understand that @aheejin is attempting to get the MVP for zero-cost exceptions ready, so C++ becomes partially compilable to WebAssembly. I say "partially", because getting C++ exceptions to work still does not solve the question of getting C++ destructors to work. If these are expressed in terms of throw and catch, then this approach will make a major clash with SJLJ-style cheap jumps, which are essential in some other programming languages. This clash is a problem in the real world[1].


My main question right now is: does WebAssembly need to care at all about this distinction?

The rationale for my question is that Technical Report on C++ Performance, chapter 5.4.1, lists two distinct approaches for handling exceptions in C++: the "code" approach (5.4.1.1), which is the SJLJ style, and the "table" approach (5.4.1.2), which is the ZCEH style. The important implication of this document is that the same C++ code, which is going to have some combination of trys, catches, and destructors, can be compiled from the very same source code in two semantically equivalent ways that yield different performance characteristics.

This means that C++ code is actually exception-style-agnostic, meaning, it does not care which exception model is used in the compiled binary under the hood. (Such is also the case with e.g. Lisp code, except I don't know any Lisp implementation that offers the "table" approach to control flow in practice.)

I think that it would be possible to utilize the same technique in WebAssembly. It should be enough to compile C++ (and other languages) into a WebAssembly form that preserves enough information about try scopes and available desctuctors/unwinders for the second-phase compiler (which processes WebAssembly into native code; I assume that around here it is called implementation) to be able to implement the chosen exception strategy itself.

The choice of strategy could be static or dynamic, and e.g. C++ modules which depend on the fast optimistic path could be compiled with ZCEH whereas e.g. Lisp modules which depend on fast unwinds could be compiled with SJLJ. It would be up to the implementation to provide bridging between modules with different exception handling styles.

This would have the benefit of keeping the WebAssembly control flow and exception handling representation as language-agnostic as possible and therefore make it possible to build implementations that leverage this fact by optimizing for fast no-unwinds or fast-unwinds respectively.

Such an approach would effectively defer the decision of whether to use ZCEH or SJLJ to the WebAssembly implementation, and, therefore, it would solve this current discussion that we are having.


The current specification may need to be adapted to match the above goals. Speaking generally: there should be some kind of way to record dynamic environments and some way to record continuation marks - where:

  • the term "dynamic environments" encompasses all of try blocks, unwind-protects, dynamic/fluid variable bindings, and whatever other things programming languages may want to do;
  • the term "continuation marks" means data attached to frames, available for code that later introspects those frames (with the exception of stack-allocated variable data) - see https://docs.racket-lang.org/reference/eval-model.html#%28part._mark-model%29 for Racket's definition of the term.

For C++, this means information destructors and a map of exception types to catch blocks; for Lisp, that would be unwind-protect thunks, dynamic variable bindings, and information about exits. I'll need programmers and implementers of other programming languages to chip in with regard to what their own languages would require - especially if these languages perform uncommon control flow.

When that information is present in WebAssembly, an implementation will be allowed to retain this information at runtime for a more dynamic, SJLJ-style approach to unwinds, or to precompute the unwinding/exception tables and optimize the code for non-unwind scenarios, ZCEH-style.

I have identified several ways in which the current proposal could be adapted to fit the above description; this list is by no means exhaustive and is only valid if there are no errors with my reasoning above.

  • Explicitly support unwinders via WebAssembly primitives, rather than expressing them in terms of rethrow and catch. This is because such an approach is valid in ZCEH but not in SJLJ, where unwinders are usually implemented in terms of functions/instruction blocks and have their own stack (that could be implemented in userspace);
  • Remove the exception stack, which is AFAIK specific to C++ only and of little or no benefit to other languages or to the SJLJ style (and could be implemented in userspace);
  • Remove the rethrow operator which seems redundant as a primitive; it should be possible to have rethrow as a userspace function which never returns.

Does all of the above make sense?

I've tried to speak from the C++ perspective for a moment - I'm not a C++ programmer, so please forgive and correct me if I'm wrong somewhere here. I did consult that with Clasp Common Lisp programmers though, so this should be good for both C++ and Lisp perspectives.


[1] I've asked some people behind Clasp Common Lisp to write this down; this document describes the nature and issues with performing control flow and unwinds in a C++-centric environment.

@aheejin
Copy link
Member

aheejin commented Dec 8, 2020

@phoe

As you noted, the current proposal was designed with the zero-cost style EH in mind, but it can support some longjmp style primitives, such as C++'s longjmp. As you said, it may have a different performance characteristics, and it is possible we happen to support a language that needs a non-local control flow that does not require zero cost EH for normal path but requires a cheaper cost for the nonlocal flow. If the need arises, someone can come up with a new proposal for that. It is not a goal of this proposal to support every language out there seemlessly, or support every kind of existing performance characteristics.

Also while we are open to minor revisions, I would really like to refrain from undertaking a major overhaul or redesigning the whole proposal at this point; the proposal has been around for more than three years now, and we also have mostly-working toolchain and two VM implementations. Especially, removing existing functionalities can cause unintended consequences.

These two issues are also mostly orthogonal to the third, which is essential for implementing C++ destructors/Java's finally blocks/Lisp's unwind-protect. One troublesome part is that unwinds are also implementable in userspace on top of either of the two first proposals, which then causes the aforementioned issue of earlier, de-facto standards clashing with later, "actual" standards. Another, bigger issue, is that there is no exception-style-agnostic way to perform unwinds - they are encoded and performed differently in case of code that utilizes ZCEH-style and SJLJ-style exceptions. This means that it is not possible to design a single low-level stack unwinding mechanism that is going to work both for ZCEH-style and SLJL-style exceptions while respecting the performance requirements of these two approaches.

If these are expressed in terms of throw and catch, then this approach will make a major clash with SJLJ-style cheap jumps, which are essential in some other programming languages. This clash is a problem in the real world[1].

I'm still not sure what "de-facto standards" we have. Also I'm really not sure what you are proposing or what clash we are having. Do you think the current unwind instruction suits your purpose? If so, you can use it. If not, why is it not compatible with your purpose?

I skimmed the doc you linked, but I have a very limited understanding of Common Lisp, so I'm not sure if I understood the doc properly. A summary of the problem you are talking about would be appreciated.

The current specification may need to be adapted to match the above goals. Speaking generally: there should be some kind of way to record dynamic environments and some way to record continuation marks - where:

  • the term "dynamic environments" encompasses all of try blocks, unwind-protects, dynamic/fluid variable bindings, and whatever other things programming languages may want to do;
  • the term "continuation marks" means data attached to frames, available for code that later introspects those frames (with the exception of stack-allocated variable data) - see https://docs.racket-lang.org/reference/eval-model.html#%28part._mark-model%29 for Racket's definition of the term.

For C++, this means information destructors and a map of exception types to catch blocks; for Lisp, that would be unwind-protect thunks, dynamic variable bindings, and information about exits. I'll need programmers and implementers of other programming languages to chip in with regard to what their own languages would require - especially if these languages perform uncommon control flow.

Including all this info within the spec, especially this MVP, is really not a goal for the MVP proposal. Some information is already generated and supplied with the appropriate toolchain support. The current C++ EH needs some of these and the toolchain embeds necessary information (LSDA tables in this case) in the data section of the binary. And as I said, if you want a type of unwinding that unwinds to a specific marker, you would probably need a new proposal.

I have identified several ways in which the current proposal could be adapted to fit the above description; this list is by no means exhaustive and is only valid if there are no errors with my reasoning above.

  • Explicitly support unwinders via WebAssembly primitives, rather than expressing them in terms of rethrow and catch. This is because such an approach is valid in ZCEH but not in SJLJ, where unwinders are usually implemented in terms of functions/instruction blocks and have their own stack (that could be implemented in userspace);

I'm not sure what you mean by explicitly supporting unwinders. As I asked above, does unwind suit your needs? Then you can use it.

  • Remove the exception stack, which is AFAIK specific to C++ only and of little or no benefit to other languages or to the SJLJ style (and could be implemented in userspace);
  • Remove the rethrow operator which seems redundant as a primitive; it should be possible to have rethrow as a userspace function which never returns.

These have uses unrelated to unwinding, so it is hard to remove them. We have many existing discussion threads for this such as #126 or #127, so please refer to them. I'm also not sure why existence of these instructions is a problem for your use case. You may want to add some functionalities for, such as Common Lisp support (but as I said we would like to make the MVP simple and defer adding a new functionality to a future proposal), but I wonder why existence of these instructions is a problem for your use cases.

ioannad added a commit to ioannad/exception-handling that referenced this issue Mar 12, 2021
This is an attempt to formally describe @aheejin's 3rd proposal, which she presented to the Wasm CG, and which was voted to be the new EH proposal, on September 18, 2020. This is not formal spec that I have developed, but a formal description of this 3rd proposal.

This is a reworked form of my [first attempt on this formal spec overview](WebAssembly#87 (comment)) edited with my new understanding of the spec based on the discussion below, and in other issues, and according to @aheejin 's [3rd proposal overview](https://github.com/WebAssembly/exception-handling/blob/f7a4f60d11fb6326fc13f84d3889b11d3873f08a/proposals/Exceptions.md) in PR WebAssembly#137.

This is in the form of a document as @tlively [requested](WebAssembly#142 (comment)), to make discussion on specific points easier.

I wrote this formal spec overview roughly in the style of the 2nd proposal's [formal spec overview](WebAssembly#87 (comment)) by @rossberg.

Particular points of interest:

- In this I assume `rethrow` does have an immediate, as it is now described in WebAssembly#137.
- The instruction `unwind` now reduces to `catch_all ... rethrow` as specified in Heejin's overview.
- Because unwind is much simpler in this MVP, there is no `throw_point` anymore and `caught_m` is much simpler.
- The introduction of `caught_m` now also introduces a label around the catch instructions, which label a rethrow instruction can reference.
- Added explanation of the peculiar side condition in try's execution rule - just trying to make the rules more compact.

I would be happy if anyone could point out things that are wrong or that I misunderstood.
Ms2ger pushed a commit to Ms2ger/exception-handling that referenced this issue Jun 24, 2021
Fixes WebAssembly#142. A mismatched `DataCount` is malformed, not a validation error.
ioannad added a commit to ioannad/exception-handling that referenced this issue Jun 25, 2021
This is an attempt to formally describe @aheejin's 3rd proposal, which she presented to the Wasm CG, and which was voted to be the new EH proposal, on September 18, 2020. This is not formal spec that I have developed, but a formal description of this 3rd proposal.

This is a reworked form of my [first attempt on this formal spec overview](WebAssembly#87 (comment)) edited with my new understanding of the spec based on the discussion below, and in other issues, and according to @aheejin 's [3rd proposal overview](https://github.com/WebAssembly/exception-handling/blob/f7a4f60d11fb6326fc13f84d3889b11d3873f08a/proposals/Exceptions.md) in PR WebAssembly#137.

This is in the form of a document as @tlively [requested](WebAssembly#142 (comment)), to make discussion on specific points easier.

I wrote this formal spec overview roughly in the style of the 2nd proposal's [formal spec overview](WebAssembly#87 (comment)) by @rossberg.

Particular points of interest:

- In this I assume `rethrow` does have an immediate, as it is now described in WebAssembly#137.
- The instruction `unwind` now reduces to `catch_all ... rethrow` as specified in Heejin's overview.
- Because unwind is much simpler in this MVP, there is no `throw_point` anymore and `caught_m` is much simpler.
- The introduction of `caught_m` now also introduces a label around the catch instructions, which label a rethrow instruction can reference.
- Added explanation of the peculiar side condition in try's execution rule - just trying to make the rules more compact.

I would be happy if anyone could point out things that are wrong or that I misunderstood.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants