-
Notifications
You must be signed in to change notification settings - Fork 830
Improve async stack traces #4867
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
Conversation
Oh yes yes yes |
|
I've realised as a side effect of this that our F# async exception implementation is hitting this problem badly, where only one stack trace is included in the .StackTrace property of the exception. This is partly because our F# async machinery catches the exception “early”. If someone knows a way, somewhere deep in .NET Framework or .NET Core, by which to attach a deeper stack trace string to an exception that has already been thrown, I'd be very grateful. We’re willing to do anything to make this happen: create a task, whatever. I’ve even considered using the reflection hack documented here to set the _stackTraceString field, which Eirik Tsarpalis has also used. The one thing we can’t do is wrap the exception in an AggregateException – we have to throw the user’s exception. |
I think I've found an approach that fixes this. Essentially we let exceptions propagate all the way up to the trampoline and deal with them there. When combined with the rest of this work this should give much, much better exception stacks For example for the code we get exception stack trace instead of If you somehow ignore instead of just |
|
@dotnet-bot test this please |
|
Yay, it's green! |
|
Note: we need to double check the stepping and breakpoint behaviour of async debugging after this change. |
I've done that now - stepping (F11) is as good as it has been before. step-over (F10) is also as good as before (though is not great or intuitive) |
|
This is ready for further road testing |
|
Awesome! Congrats.
|
|
This PR is no nirvana, but it does make some improvements. Some of the glitches remaining (even in Debug code) are
When I look at the code we generate for async, I can see room for many improvements, both from performance and debugging perspectives. But we should get something in the bank, so I'm going to stop here for now. |
* Fixing #4967 (#4968) * Fix internal error when compiling interface implementation lacking an overloaded method implementation - fixes #4967 * Adding a test * Update E_OverloadMismatch.fs * Remove a setify + isSingleton combo (#4980) * remove a setify * Remove intermediate collection * Improve async stack traces (#4867) * very early prototype * async stack traces * async cleanup * minor fix * async cleanup * more async cleanup * integrate async-cleanup * async cleanup * fix build * more cleanup * minor fixes * minor fixes * full exception stacktraces * fix test * fix test * code review * cleanup naming * fix build * undo rethrow and integrate cleanup * apply renamings * Further cleanup in control.fs * add tests and add filtering TryWith, plus other cleanup * integrate cleanup * fix tests * test only runs on .net framework * slightly tweak primitives to be more suitable for later optimization * slightly tweak primitives to be more suitable for later optimization * update baselines * add check that no line 0 appear in stack * update baseline * use struct wrapper for async activation * simplify code * simplify code * update baselines * update baselines * fix baseline * remove dead code * simplify code * apply DebuggerHidden in a couple more places * [ RFC FS-1039] implementation of value options (#4837) * posible implementation of value options * fix surface area test * fix test * VNone --> ValueNone * fix surface area * fix build * update baselines * fix baselines * fix baselines * fix baselines * fix baselines * fix build
matthid
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just some smaller things that came to mind when looking at the changes.
|
|
||
| /// Save the exception continuation during propagation of an exception, or prior to raising an exception | ||
| member __.OnExceptionRaised (action: econt) = | ||
| storedExnCont <- Some action |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't we check if the storedExnCont is already Some value and throw (which I guess indicates a programming error)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually on second look we already do this for storedCont so another indication this might be a good idea.
|
|
||
| member ctxt.WithExceptionContinuation econt = AsyncActivation<_> { contents with aux = { ctxt.aux with econt = econt } } | ||
|
|
||
| member ctxt.WithContinuation(cont) = AsyncActivation<_> { cont = cont; aux = contents.aux } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not { contents with here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@matthid The type of the object changes, and {a with ... } assumes the type of the expression is exactly the same as a
| if thisIsTopTrampoline then | ||
| Trampoline.thisThreadHasTrampoline <- false | ||
| FakeUnit | ||
| Unchecked.defaultof<AsyncReturn> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is essentially null, correct? Should we add AllowNull attribute and use null? And add a comment that only null is used as value for this type?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is just a question, I guess the current solution is fine - just a bit unexpected when looking at the type
|
|
||
| /// <summary>The F# compiler emits references to this type to implement F# async expressions.</summary> | ||
| [<Struct; NoEquality; NoComparison>] | ||
| type AsyncActivation<'T> = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we add some attribute here like CompilerGenerated or Obsolete or something else to indicate users should not use the type directly?
|
@dsyme would you consider an RFC for this? No need to be super detailed. Just want to have the RFC repo accurately reflect major changes going into F# 4.5/FSharp.Core 4.5.0.0 here: fsharp/fslang-design#311 |
|
@cartermp I'll write up something |
The stack traces for F# async are not good see #2741. This PR starts to improve them (though doesn't solve the problem anywhere near completely)
Summary
Debugging computationally embedded languages gives bad stack traces. This PR outlines
Problem One - losing stack traces for computationally embedded languages
There is a fundamental problem with debugging for pretty much all "computationally embedded DSLs" which delay computation. Many basic DSLs suffer this problem, both in functional and object programming.
For our purposes, "Computationally embedded DSLs" are ones where:
async { ... })The process of execution may dynamically create more computation objects along the way during phase2, though that's kind of irrelevant.
A very, very simple example of such a "computationally embedded language" is below. This DSL has lousy debugging in F#:
which lets you write things like:
Now the "stack" when the
ret 6computation off1()is actually "invoked" (i.e. when it gets invoked as part off3() |> run) is crappy. You can see the crappy stack by augmenting the example as follows:Here is the stack:
Barely any user code is on the stack at all! The line numbers are all the closures in the implementation of the DSL!!! Ugh!! 😞 👿
Note that C# async and other implementations of Promises/Tasks/"Hot Tasks" don't suffer this problem since they are not "delayed". But most F# DSLs (and many C# ones) are indeed "delayed". For async, it is F#'s use of "cold" asyncs that require explicit starting that causes the issue. Using "cold" asyncs brings many benefits - most particularly the implicit plumbing of cancellation tokens - but also bring problems.
Problem Two - Losing stack traces for exceptions
There is a second fundamental problem with .NET stack traces for any computationally embedded DSL that may throw .NET exceptions. This is based on this problem, where only a limited number of stack frames are included in the
.StackTraceproperty of a .NET exception when it is thrown, up to the first catch handler.This is a serious and surprising limitation in the CLR, and I think it would be great to fix it. For example it’s a very common pattern in functional programming to quickly turn exceptions into data, e.g. consider basic patterns like the Railway Oriented programming pattern. This might have some code like this:
Here the
StackTracedata for the exception carried by theErrordata will only carry a partial stack trace containing one stack frame. See the link above for why - but basically the CLR only adds stack frames up to the firsttry ... withhandler. Ugh. From the user’s point of view that’s not what they normally need or expect. But it's what the CLR does.There are C# equivalents to such patterns, especially with C# becoming more and more expression-oriented. They suffer the same problem.
Problem One - What can we do?
Now, what cards can we play to fix this? In general, it seems "hard". Here are the options I know of:
Keep causal stacks in objects and reinstate them when the behaviour of the objects is activated.
This could be done if there was a way to do some kind of
[<CaptureCallerStackToken>]and pass ti down. We can simulate that by capturing a System.Diagnostics.StackTrace for each and every computational object created and passing them down.This could give very nice stack traces, but you would need to be able to hijack the normal debugging mechanisms and say "hey, this is the real causal stack". This can be done for exception stack traces by hacking the internals of a .NET exception object (see this blog post).
This is expensive. We can't normally afford to keep stacks from the creation of objects in the actual object closures.
In some sense, this is what C# async debugging is doing. However I don't yet understand the mechanism C# async is using or if it is reusable for other computationally embedded languages. It is possible that it is, by emitting just the right calls and attributes. That's future work, any advice or links appreciated.
It is also roughly what is done for rethrow exceptions using ExceptionDispatchInfo. However AFAIK this can't be done for stack traces at breakpoints.
We could pass source/line number information into the process of constructing the computational objects. This was done by Eirik Tsarpalis here in order to get better exception stack traces. However this feels expensive - or at least not cost free - and also requires changing API surface area. Also, it is only helpful for stack traces produced for exceptions.
The third option, and the one used here, is to "inline" the key parts of the computational language (and its composition operations) so that the key closures which implement the composition become user code. This relies on the fact that when the F# compiler inlines code, it re-tags closures contained in that code with the source information of callsite.
Here is an example of how we can play this game with the very simple computational language above:
The rules we've applied are:
applyand mark themDebuggerHiddenThe resulting stack trace (excluding
DebuggerHiddenmethods) isThis is not perfect - we see the generated closure names - but at least the line numbers are right and names
A.f1andA.f3appear. This is a significant improvement.Problem Two - What can we do?
One way to improve the exception stacks for problem 2 is to use a "trampoline" to run the synchronous parts of computations. When an exception happens, the exception continuation (or other information required to continue execution) is written into the trampoline.
This technique works for async since we have a trampoline available.
Note that the stack trace is lost when rethrowing an exception. Sadly this means that it is lost when an implicit rethrow happens with this sort of thing:
You are encouraged to do something like this pattern to keep good InnerException information:
What this PR actually does
This PR applies technique #3 to async. The steps:
The Bind composition operation in
AsyncBuilderis markedinlinealong with a couple of others.An extra level of underlying async execution machinery is exposed in the API of FSharp.Core. The new API surface area is in control.fsi in the PR - all of the added APIs are for generated code only. All of these will be marked
compiler use onlybut become part of the long-term binary compatible API for FSharp.Core. This is "just enough" to allow key closures to be inlined into user code, but not too much that we can't re-implement how Async is implemented at a later stage.Limitations
Only improves debug user code that is compiled with tailcalls off
Improves the debugging experience for first throw of exceptions and the synchronous parts of asynchronous code.
The stack is still lost if
The improvements need a modified FSharp.Core.dll (i.e. F# 4.5)
Remaining Steps
return! asyncExprresults in the loss of a debug stack frame, it is always treated as an async tailcall even if tailcalls are off. See this commentTryWith, consider doing this.: Now doneLater:
cancellableandeventuallyin the F# compiler codebase