-
Notifications
You must be signed in to change notification settings - Fork 35
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
Cannot properly unwind stack as a second phase #122
Comments
You said the current design is not compatible with the second phase of the unwinding, which I don't fully understand. I think it can be more of a problem for the first phase, because it has to search catches, and with control flow escaping, it is hard to determine what the next
Not sure what 2 means. Are you referring to resumable EH? I'd like to avoid mixing discussions for resumable EH with two-phase unwinding. |
Huh, so this is a second issue for the first phase that I hadn't though of. The issue I had thought of was that the first phase needs to not execute unwinding code associated with Due to either issue, I had figured any language needing filtered (or resumable) exceptions would use a different event type or
I am, but resumable EH is best implemented with two-phase EH (though there's a common misconception that it requires continuations). This blog post is itself not very informative, but it does link to a bunch of useful discussions on the topic (including addressing the misconception regarding continuations). The following is an excerpt about implementing resumable EH with two-phase EH:
Even without adding resumable exceptions to C++, the feature can be useful for implementing C++. In particular, That all said, if you would prefer to put resumable exceptions aside for the sake of focusing the discussion, that's fine with me. I just first want it be clear that they are in the scope of two-phase EH, and even within the scope of implementing C++ exception handling (among other languages). |
OK, apparently the problem I thought I realized was different from the problem you have been talking about, which I still don't understand. And it has been hard to tell which situations you are referring to. Are you referring to the actions of hypothetical follow-on proposal only? Or are you talking about the ABI break, or module compatibility between modules compiled with current EH and the hypothetical follow-on EH proposals?
If the first phase does not execute any code in Also, I still don't understand why you think it's a problem for the second phase. You continuously mentioned continuations in #118, which was confusing. I asked you many times why we need it, but I only got two answers: "@rossberg said we need it (only he didn't; he said it for resumable EH and not two-phase EH)" and "Then how are you gonna make this run without continuations?" As a person who don't even understand why continuations are necessary as you suggest in the first place, I don't know how to answer the second question myself. (I asked other few people who joined discussions in #118, but they don't seem to understand it either.) Are you saying we need continuations when we run two modules, one of which is compiled with the current EH proposal and the other is compiled with the hypothetical follow-on EH proposal, together? I think module compatibility is a completely separate issue which we should talk about separately, but I'm not sure if you are talking about this or not.
Not sure what you mean. Shouldn't we run unwinding code in the second phase? Did you mean first phase?
I'm not sure what you mean in this paragraph. In case you think we did some own implementation to support these features, wasm EH library does not even modify libc++ part, which contains all those functions you mentioned. All modifications to libraries are in libc++abi, and those modifications are also mostly contained in the personality function. We also have our own libunwind, which contains EH instructions to trigger VM to unwinder the stack. (And we don't have much more "plan" to add something much more to our current libc++abi at this point. In case you are referring to the plan related to rethrow in reply to your mail, as you pointed out somewhere else, C++'s rethrow throws out stack trace, so I guess we don't need that. Of course, libc++abi itself manages its own exception stack, but we haven't and don't plan to change that part.) |
(Sorry this is a lot of text. In fact it's really 2 posts because I initially wrote the first section to help me understand and clarify, and then I realized what I think is actually our more fundamental mismatch in assumptions. So if this is tl:dr, then at least read the last part) What should happen when MVP exceptions mix with future 2-phase exceptions?I wanted to try to clarify what I think the central issue here is (this post is already a big improvement in that direction compared to #118 but I think we can start even narrower). This basically mirrors what I said in #118 but I think the claim here is not that a 2-phase To clarify the goal here, what do we want to happen in this case? Suppose we did have a system with an MVP that had a statically-delimited catch/unwind block (no exnref) and then added 2-phase via a filter clause associated with each unwind block (and suppose you can rethrow from inside the unwind block, leaving aside for now the possibility of splitting handling from unwind code). Here's my guess: Ross indicated in your most recent comment that we would skip these unwind blocks during the first phase, and then execute them as usual during the second phase. If the exception is unhandled it, gets rethrown before the unwind block is exited and unwinding continues. We expect the exception to be unhandled because it is foreign to the MVP code. But the MVP currently allows for foreign exceptions to be handled too. So if the exception is handled we fall (or branch) out of the catch block, and at this point we know unwinding is completely done. In either case we know whether this frame's unwind code is finished or not based on our static scope, although the exact point at which unwinding continues or ends is dynamic, because a rethrow or a branch could be conditional. We also always know statically where unwinding will continue to, because its the scope that surrounds the catch block (or the callsite of the current function) Edge cases(Here I'm just exploring other consequences of this idea; ignore this paragraph if you disagree with the above). In this case we don't know during the first phase whether MVP frames will eventually handle this exception or not. So it could happen that phase 1 finds a handler further up the stack and begins the unwind phase, but the exception is instead handled by the MVP frame. This seems probably ok to me. It could also happen that no 2-phase-aware handler is found and we invoke the debugger or whatever after the first phase (instead of running the second phase), but the exception would have been handled by an MVP frame. This seems like it could be undesirable, but not the worst thing in the world. The problem with exnref escape(Here I'm attempting to restate what I think is Ross's characterization of the problem). The real mismatch in our assumptionsEdit: I wrote all of the above which helped me clarify, but I think I figured out what the fundamental disconnect is: In that case, it doesn't matter what happens when the exception escapes, other than the fact that it keeps a reference to the thrown object. This behavior is perfectly sufficient for solving the unwind-mismatch issue (since that problem is localized to a particular try block) but might be surprising. In fact we have been imagining that it might be possible to preserve the original JS/wasm stack trace on a rethrow, but I think that if an exnref does escape its function of creation then that becomes complicated/impossible without making the exnref keep much more information than just the thrown object). Anyway I think we should be clear on what our assumptions are about rethrowing before we think about what extending an exnref-based proposal would mean. |
Thanks, @dschuff, for the thorough digest! Given the fundamental limitations of the format, it's hard to say for certain that we have the same understanding of the issues, but my sense is that we're roughly on the same page except for the details of rethrowing/escaping, but I think that's your point: we should get a mutual understanding of that pinned down.
Yes, I understand that that is the intent of
Yes, and this causes the problem: how does unwinding know where to continue to (both up to where on the stack, and where in the code to transfer control to when it's done) in the new scope, and how does it know that that destination is even still valid in the new scope? Sorry if you already understood this; I wasn't entirely sure what parts were questions and what parts were statements. |
The point is that the destination is determined by the new scope. It's exactly as if you threw a fresh exception. |
But how does it know where to stop? The problem would be much easier to illustrate with a whiteboard, but I'll try with text another way. In the first phase, the stack is walked to look for potential handlers. When a potential handler is found, its code is run on the bottom (leaf?) of the stack being walked. That way it can call whatever functions it needs to figure out which of the actual handlers it has is a fit, if any. If it does, then the relevant code pointer and stack pointer are saved in registers and the unwinding process begins. This process pops off frames until it either reaches the saved stack pointer or it encounters an unwinder. Without The important thing to note is that the destination stack and code pointer are saved throughout the entire unwinding process (as are all the values to forward to the given code pointer, but I'm ignoring those for now for simplicity). Critical to this was treating each unwinder on the stack like a function, saving these values on the stack and then popping them off when the unwinder function completes. But unwinding with Does that better illustrate the problem? I'm up for meeting over Zoom, where I imagine a (digital) whiteboard would be very helpful. |
What are "these values"? Not exactly sure what you mean by "it is critical that they are treated like a function" either. Those frames that contain unwinder code are already on the stack at the point when unwinding starts. We pop those frame off while we unwind, but don't understand why we should create a new stack frame for each unwinder, if that's what you mean.
The VM can store information on the destination catch. While in the process of unwinding, if the control flow is transferred to that catch, the VM can know that this is the destination. No? Let me ask you another question: why can the first phase find the destination while the second phase can't? What's the ability the first phase possesses that the second phase doesn't? (Disclaimer: I actually think the first phase can't; I explained this in #122 (comment)) And could you answer my questions in #122 (comment)?
@dschuff also wrote a long paragraph about interaction between MVP-compiled module and new-proposal-compiled module, but we are not even sure about whether you are talking about the hypothetical two-phase proposal only or the possible compatibility issue between when modules compiled with first and follow-on proposals run together. |
I'm ok with meeting over Zoom. When are you available? I'm basically OK all day today going forward and anytime tomorrow except for 1-2pm PDT. |
Here you are getting a bit ahead of what I was trying to say before: I was just trying to say that as currently specified, exnref carries no continuation information or pointers into the stack, etc. So escape doesn't matter. But adding 2-phase brings up a problem. And I think you, Heejin, and I are mostly just talking about the same problem or class of problems (with a focus on different aspects and using different words). I would frame the problem as being a problem of mismatch between the unwinding paths taken by the 2 phases; i.e. which catch/unwind blocks (or associated filter blocks) are entered as unwinding continues. I think we all agree that for 2-phase unwinding to work, the paths must match. In Heejin's formulation, the path taken by the second phase is the "canonical" path. And that path is determined not by the scope surrounding the catch block, but dynamically based on the location of the rethrow. In this formulation, the problem is to make the path taken by the first phase match that canonical path, but there's no way to do that statically. That's why she's characterized the problem as being a problem with the first phase (whereas you are talking about the problem being associated with the second phase). |
Ah, I think I see what you're saying and how it suggests a convergence of ideas from two different directions. That perspective will help with tomorrow's meeting. Thanks! |
Many of concerns here were focused on |
First, before going any further, I want to clarify that this issue is not about extending the current proposal with two-phase exception handling; rather, the issue is about the potential for such an extension.
Motivation for Two-Phase Exception Handling
Exception handling is often implemented in two phases:
Many languages are designed to support both single-phase stack unwinding (in which the stack is unwound while inspecting the stack) and two-phase stack unwinding. But many tools and languages specifically require two-phase stack unwinding, such as interactive debuggers wanting the stack to be in tact when intercepting an uncaught exception, and such as C#
when
clauses requiring (stateful) user-specified exception-filtering code to be run before unwinding code like C++ destructors and C#finally
blocks is executed. Furthermore, due to WebAssembly's general-purpose goal, many languages will likely need at least custom filtering/delegating wasm code to be run just to find out if a given handler is actually intended for a given exception. Thus it seems likely that WebAssembly will eventually need to support two-phase exception handling.Designing for Two-Phase Exception Handling
In general, this first-phase code will need to run arbitrary instructions and inspect the exception at hand in order to decide to do one of the following:
catch
clauses associated with a given surface-leveltry
block) after unwinding the stack.Note in particular that the second phase—stack unwinding—only happens in this third case and it has a destination: where on the stack to unwinding up to and where in the code to delegate control to after stack unwinding is finished.
Incompatibility with Two-Phase Exception Handling
Now consider the current proposal and what it would require to extend it with a design for two-phase exception handling. The first phase would have to skip the current
catch
blocks in order to avoid executing unwinding code associated with them. That means that the second phase, if it happens, would need to somehow execute this unwinding code. The issue is that the unwinding code incatch
blocks is not clearly delimited, so there's no easy way for a second phase to know when unwinding associated with a particularcatch
is done and it can move on to unwinding further up the stack (or to transferring control to the target destination). One might think that theend
of thecatch
block is clearly such a static delimiter, but realize that it's perfectly valid for acatch
block to simplybr
to some label expecting anexnref
. In reality, the delimiter is determined dynamically when theexnref
that was caught by thecatch
is passed torethrow
. That's why I'm being careful to use the phrase "unwinding code associated with" rather than "unwinding code within".Due to this dynamic determination of the end of unwinding code via
rethrow
, the second phase's only option for interoperating with the currentcatch
blocks seems to be to give acatch
that needs to be unwound a specialexnref
that specifies how to continue the second phase once theexnref
is rethrown.One issue this raises is that during the first phase one would like to keep track of the unwinders on the stack that were encountered while searching for a suitable handler in order to quickly iterate through them in the second phase, but because an
exnref
can escape the scope of thecatch
block this list of unwinders can be invalidated, meaning one has to search for the next unwinder each time the specialexnref
is rethrown.Another issue is that the special
exnref
needs to store the information for continuing the second phase once it's rethrown. In particular, it needs to store the destination of the second phase, say as a combination of a stack-frame pointer (how far up the stack to unwind to) and a code pointer (the address of the code of the determined handler). But this specialexnref
is a first-class value. It can be passed to and rethrown from another thread, or it can be passed to and rethrown from another stack that may or may not be (temporarily) fused with the one theexnref
points into. How do we specify the semantics of these unintended interactions of features? Worse yet, theexnref
can outlive the validity of the stack frame it points to. Even worse, that stack frame can be replaced with a new stack frame so that it appears valid, but that new stack frame might not be associated with the code pointer in theexnref
, making it unsound to redirect to.Conclusion
Based on my analysis above, the current design seems to make any extension of itself to two-phase exception handling at the least less efficient, likely excessively complicated (at least to formalize and reason about), and possibly unsound or intractable. The primary cause of these problems seems to be the dynamic, rather than static, delimiting of unwinding code, and in particular dynamically delimiting via a value that can escape the stack being unwound or can outlive the validity of the unwinding destination.
The text was updated successfully, but these errors were encountered: