Open sandboxFocusImprove this doc

Universal interceptor example, step 2: Handling async methods

The Invoke method of Universal interceptor example, step 1: Routing synchronous calls to one object returns the result of the call. For an async method, this result is a task, so Invoke would return before the operation completes, and it could not observe the result or the exception of the operation. This article explains how the async methods of WeatherClient reach the interceptor.

The DescribeForecastAsync method of WeatherReport calls methods that return a Task, a ValueTask, a Task<T> and a ValueTask<T>:

31    // Methods that return a Task, a ValueTask, a Task<T> and a ValueTask<T>.
32    public async Task<string> DescribeForecastAsync( string city, int days )
33    {
34        await this._client.RefreshAsync();
35        await this._client.ClearCacheAsync();
36        var forecast = await this._client.GetForecastAsync( city, days );
37        var current = await this._client.GetTemperatureAsync( city );
38
39        return $"{city}: {current} now, {forecast.AverageHigh} on average in the next {forecast.Days} days";
40    }
41

When the program calls DescribeForecastAsync, CallLog writes the following lines:

[call] WeatherClient.RefreshAsync(CancellationToken) succeeded asynchronously.
[call] WeatherClient.ClearCacheAsync(CancellationToken) succeeded asynchronously.
[call] WeatherClient.GetForecastAsync(Prague, 3, CancellationToken) returned Forecast { City = Prague, Days = 3, AverageHigh = 13.5 } asynchronously.
[call] WeatherClient.GetTemperatureAsync(Prague, CancellationToken) returned 12.5 asynchronously.
Prague: 12.5 now, 13.5 on average in the next 3 days

The cancellationToken parameters are optional, and the call sites do not pass them. The generated interceptor has the same parameters as the intercepted method, so the call site passes default(CancellationToken), and the tuple contains it.

The run-time interface

IUniversalInterceptor has two InvokeAsync overloads, one for Task<TResult> and one for ValueTask<TResult>:

21    // Intercepts a call to a method that returns a Task or a Task<T>. For a Task, TResult is ValueTuple.
22    Task<TResult> InvokeAsync<TArgs, TResult>(
23        TArgs args,
24        InterceptionMetadata metadata,
25        Func<TArgs, Task<TResult>> proceed )
26        where TArgs : struct, ITuple;
27
28    // Intercepts a call to a method that returns a ValueTask or a ValueTask<T>. For a ValueTask, TResult is ValueTuple.
29    ValueTask<TResult> InvokeAsync<TArgs, TResult>(
30        TArgs args,
31        InterceptionMetadata metadata,
32        Func<TArgs, ValueTask<TResult>> proceed )
33        where TArgs : struct, ITuple;
34

A method that returns a Task or a ValueTask has no result, so the generated code uses ValueTuple as TResult, as for a void method. Two overloads thus handle the four return types.

The proceed delegate is a Func<TArgs, Task<TResult>> or a Func<TArgs, ValueTask<TResult>>, and it receives the tuple by value. An async method cannot have ref parameters, so the delegate cannot write the arguments back to the tuple. For this reason, the methods that return an awaitable type and have ref or out parameters are not intercepted. See Universal interceptor example, step 3: Handling ref and out parameters.

The logger

CallLog implements InvokeAsync with async methods. It awaits proceed, so it writes the result or the exception when the operation completes:

44    public async Task<TResult> InvokeAsync<TArgs, TResult>(
45        TArgs args,
46        InterceptionMetadata metadata,
47        Func<TArgs, Task<TResult>> proceed )
48        where TArgs : struct, ITuple
49    {
50        var call = FormatCall( args, metadata );
51
52        try
53        {
54            var result = await proceed( args );
55
56            Console.WriteLine( $"[call] {call} {FormatResult( result )} asynchronously." );
57
58            return result;
59        }
60        catch ( Exception e )
61        {
62            Console.WriteLine( $"[call] {call} threw {e.GetType().Name} asynchronously: {e.Message}" );
63
64            throw;
65        }
66    }
67
68    public async ValueTask<TResult> InvokeAsync<TArgs, TResult>(
69        TArgs args,
70        InterceptionMetadata metadata,
71        Func<TArgs, ValueTask<TResult>> proceed )
72        where TArgs : struct, ITuple
73    {
74        var call = FormatCall( args, metadata );
75
76        try
77        {
78            var result = await proceed( args );
79
80            Console.WriteLine( $"[call] {call} {FormatResult( result )} asynchronously." );
81
82            return result;
83        }
84        catch ( Exception e )
85        {
86            Console.WriteLine( $"[call] {call} threw {e.GetType().Name} asynchronously: {e.Message}" );
87
88            throw;
89        }
90    }
91

Choosing the template

For a method that returns an awaitable type, RedirectToUniversalInterceptor chooses one of four templates from the return type. The result type is the type of the awaited value, or null for a Task or a ValueTask. Other awaitable types are not supported, and the call is not intercepted:

89// Chooses the template of a method that returns an awaitable type. The result type is the type of the awaited
90// value, or null for a Task or a ValueTask. Returns false, with the reason, when the method is not supported.
91private static bool TryChooseAsyncTemplate(
92    IMethod method,
93    out string template,
94    out IType? resultType,
95    out string reason )
96{
97    template = "";
98    resultType = null;
99    reason = "";
100
101    if ( method.Parameters.Any( p => p.RefKind is RefKind.Ref or RefKind.Out ) )
102    {
103        // The delegate that calls an async method receives the tuple by value, so the ref and out arguments
104        // cannot be written back.
105        reason = "the method returns an awaitable type and has ref or out parameters";
106
107        return false;
108    }
109
110    var asyncInfo = method.GetAsyncInfo();
111
112    switch ( (method.ReturnType as INamedType)?.Definition.SpecialType )
113    {
114        case SpecialType.Task_T:
115            template = nameof(UniversalInterceptorTemplates.InvokeTaskOfTAsync);
116            resultType = asyncInfo.ResultType;
117
118            return true;
119
120        case SpecialType.Task:
121            template = nameof(UniversalInterceptorTemplates.InvokeTaskAsync);
122
123            return true;
124
125        case SpecialType.ValueTask_T:
126            template = nameof(UniversalInterceptorTemplates.InvokeValueTaskOfTAsync);
127            resultType = asyncInfo.ResultType;
128
129            return true;
130
131        case SpecialType.ValueTask:
132            template = nameof(UniversalInterceptorTemplates.InvokeValueTaskAsync);
133
134            return true;
135
136        default:
137            reason = "its return type is awaitable but is not a Task or a ValueTask";
138
139            return false;
140    }
141}
142

The templates

The template for Task<T> awaits InvokeAsync. Its local function returns the task of the intercepted method without awaiting it:

91// For a method that returns a Task<T>.
92[Template]
93public static async Task<TResult> InvokeTaskOfTAsync<[CompileTime] TArgs, [CompileTime] TResult>(
94    [CompileTime] Type interceptorType,
95    [CompileTime] string memberName,
96    [CompileTime] bool isAsyncLocal )
97    where TArgs : struct, ITuple
98{
99    var argsType = TypeFactory.CreateTupleType( meta.Target.Method.Parameters );
100    var args = (TArgs) CreateArgs( argsType ).Value!;
101    var metadata = (InterceptionMetadata) CreateMetadata().Value!;
102    IUniversalInterceptor interceptor = GetInterceptor( interceptorType, memberName, isAsyncLocal ).Value!;
103
104    return await interceptor.InvokeAsync( args, metadata, ProceedAsync );
105
106    static Task<TResult> ProceedAsync( TArgs receivedArgs )
107    {
108        return GetInvoker( "receivedArgs", out var arguments )
109            .Invoke( arguments )!;
110    }
111}
112

The template for Task awaits InvokeAsync too. Its local function must return a Task<ValueTuple>, so it awaits the task of the intercepted method and returns default( ValueTuple ):

113// For a method that returns a Task. The result of the interceptor is a ValueTuple, which is ignored.
114[Template]
115public static async Task InvokeTaskAsync<[CompileTime] TArgs>(
116    [CompileTime] Type interceptorType,
117    [CompileTime] string memberName,
118    [CompileTime] bool isAsyncLocal )
119    where TArgs : struct, ITuple
120{
121    var argsType = TypeFactory.CreateTupleType( meta.Target.Method.Parameters );
122    var args = (TArgs) CreateArgs( argsType ).Value!;
123    var metadata = (InterceptionMetadata) CreateMetadata().Value!;
124    IUniversalInterceptor interceptor = GetInterceptor( interceptorType, memberName, isAsyncLocal ).Value!;
125
126    await interceptor.InvokeAsync( args, metadata, ProceedAsync );
127
128    static async Task<ValueTuple> ProceedAsync( TArgs receivedArgs )
129    {
130        await GetInvoker( "receivedArgs", out var arguments )
131            .Invoke( arguments )!;
132
133        return default;
134    }
135}
136

The templates for ValueTask<T> and ValueTask are identical, with ValueTask instead of Task.

The templates must be async methods. Metalama generates an async interceptor for any method that returns an awaitable type, so a template that returns the task directly produces code that does not compile.

The templates generate the following interceptors:

internal static async ValueTask WeatherClient_ClearCacheAsync_Interceptor(WeatherClient receiver, CancellationToken cancellationToken)
{
    var args = (receiver, cancellationToken);
    var metadata = new InterceptionMetadata(typeof(WeatherClient), "ClearCacheAsync", "WeatherClient.ClearCacheAsync(CancellationToken)", true);
    IUniversalInterceptor interceptor = CallLog.Current.Value;
    await interceptor.InvokeAsync(args, metadata, ProceedAsync);
    static async ValueTask<ValueTuple> ProceedAsync((WeatherClient receiver, CancellationToken cancellationToken) receivedArgs)
    {
        await receivedArgs.receiver.ClearCacheAsync(receivedArgs.cancellationToken);
        return default;
    }
}
internal static async Task<Forecast> WeatherClient_GetForecastAsync_Interceptor(WeatherClient receiver, string city, int days, CancellationToken cancellationToken)
{
    var args = (receiver, city, days, cancellationToken);
    var metadata = new InterceptionMetadata(typeof(WeatherClient), "GetForecastAsync", "WeatherClient.GetForecastAsync(string, int, CancellationToken)", true);
    IUniversalInterceptor interceptor = CallLog.Current.Value;
    return await interceptor.InvokeAsync(args, metadata, ProceedAsync);
    static Task<Forecast> ProceedAsync((WeatherClient receiver, string city, int days, CancellationToken cancellationToken) receivedArgs)
    {
        return receivedArgs.receiver.GetForecastAsync(receivedArgs.city, receivedArgs.days, receivedArgs.cancellationToken);
    }
}
internal static async ValueTask<double> WeatherClient_GetTemperatureAsync_Interceptor(WeatherClient receiver, string city, CancellationToken cancellationToken)
{
    var args = (receiver, city, cancellationToken);
    var metadata = new InterceptionMetadata(typeof(WeatherClient), "GetTemperatureAsync", "WeatherClient.GetTemperatureAsync(string, CancellationToken)", true);
    IUniversalInterceptor interceptor = CallLog.Current.Value;
    return (double)await interceptor.InvokeAsync(args, metadata, ProceedAsync);
    static ValueTask<double> ProceedAsync((WeatherClient receiver, string city, CancellationToken cancellationToken) receivedArgs)
    {
        return receivedArgs.receiver.GetTemperatureAsync(receivedArgs.city, receivedArgs.cancellationToken);
    }
}
internal static async Task WeatherClient_RefreshAsync_Interceptor(WeatherClient receiver, CancellationToken cancellationToken)
{
    var args = (receiver, cancellationToken);
    var metadata = new InterceptionMetadata(typeof(WeatherClient), "RefreshAsync", "WeatherClient.RefreshAsync(CancellationToken)", true);
    IUniversalInterceptor interceptor = CallLog.Current.Value;
    await interceptor.InvokeAsync(args, metadata, ProceedAsync);
    static async Task<ValueTuple> ProceedAsync((WeatherClient receiver, CancellationToken cancellationToken) receivedArgs)
    {
        await receivedArgs.receiver.RefreshAsync(receivedArgs.cancellationToken);
        return default;
    }
}