dd-trace-dotnet icon indicating copy to clipboard operation
dd-trace-dotnet copied to clipboard

[Debugger] Add support for probes in async methods

Open dudikeleti opened this issue 3 years ago • 0 comments

Summary of changes

Adding the ability to add probes on async methods for all supported .NET runtimes (on both Windows and Linux).

Reason for change

The .NET Live Debugger has so-far only supported adding probes on non-async method. This is a big gap :)

Implementation details

There are a few differences between the regular method probes implementation to the async one.

  1. Async methods are translated to a state machine that may be called multiple times, and in the non async probes implementation, we assume method entry and exit only happen once.
  2. In async methods, the body of the method is executed in the state machine method, so we need a way to translate between the real method (the so-called “kick-off method”) and the state machine method.
  3. We should know for each state machine execution what is the state object that holds the correct method execution info.
  4. In C# compiler there are implementation details that we need to be aware of in order to correctly extract the local variables: 4.1. Some of the original locals (from the kick off method) are hoisted onto a heap-allocated closure, whereas others which do not persist across invocations, are defined as ‘regular’ locals on the stack. 4.2. Some of the reference-type hoisted locals are nullified and value-type are set to default right before method leave. 4.3. Several locals that aren’t related to the original kick off method, are added as state machine fields or as locals.

When the user requests to put a probe on an asynchronous method, several steps occur:

  1. We extract the MoveNext method from the appropriate state machine object and ask to put the probe on it instead of the original method - the "kick-off" method.
  2. We add to the state machine object a boolean field that we will use to know whether we have already entered the MoveNext method (if we have, it means we are re-entering the method as a continuation in a subsequent await operation, and should not capture the method parameter values as we do the first time around).

When the instrumentation request occurs, we obtain the return type from the task object of the original async method. If it is not Task<T> then the return type is System.Void. Obviously referring to the return type of the original method, the MoveNext method always returns System.Void.

The instrumentation is divided into two parts:

  1. The first part is the method entry. We call to AsyncMethodDebuggerInvoker.BeginMethod and pass in the state machine type, the this object, several more parameters and the field we added to the state machine object in step 1. We pass this field by ref. Inside BeginMethod, we check that boolean field value, whether this is the first entry to the method or not. If this is not the first entry, we return from the method immediately and return an AsyncMethodDebuggerState object that we save in an AsyncLocal field. If this is the first entry to the method, we create the AsyncMethodDebuggerState object and save in it, among other things, the kick-off method (the original asynchronous method) and its parent type. In addition, we extract the arguments of the kick-off method from the state machine object as well as the local variables that live in the state machine object. We then capture the this object of the original kick-off method (as a reminder, we are now running inside the state machine object, not in the original method, so we need to take care of getting the original this), and the arguments of the original method, and then return the AsyncMethodDebuggerState object.

  2. The second part is the method exit. To know when is the final exit from the MoveNext method we take advantage of the fact that the method can exit (in a normal run) in two cases. In case of running to the end, there will be a call to SetResult at the end of the method or, in case an unhandled exception was thrown, there will be a call to SetException. So we add the call to AsyncMethodDebuggerInvoker.EndMethod in both these places. The difference between them is that in the case of an exception, we call an overload that does not return a value (returns a DebuggerReturn and not a DebuggerReturn<T>) and in the case of a SetResult we call an overload that returns a value (unless of course the original method was an async void method)

Within EndMethod we necessarily know that we have finished running the state machine so we collect everything needed (arguments hoisted in the state machine object, local variables - hoisted or not hoisted, the this object of the original method) and we then finally upload a debugger snapshot.

Without going into too much detail and actual lines of code, this is a pretty accurate description of what's going on.

Test Coverage

Tests have been added that ensure we properly capture debugger snapshots in the following scenarios:

  • Async method that return Task<T>
  • Async method that return Task
  • Recursive call to async method
  • Async method from Task.Run
  • Chain of async calls
  • Async methods with and without arguments

dudikeleti avatar Aug 15 '22 00:08 dudikeleti