Open sandboxFocusImprove this doc

Universal interceptor example, step 4: Putting it all together

The previous articles explained how a call to a synchronous method, to an async method, and to a method with ref or out parameters reaches the interceptor. This article explains the rest of the project:

  • how RedirectToUniversalInterceptor validates the member that gives the interceptor;
  • why the calls on a struct receiver are skipped;
  • how the interceptor is stored in an AsyncLocal<T>, so that tests replace it with a fake.

When the program runs, it writes the following lines:

[call] WeatherClient.SetUnit(Celsius) succeeded.
[call] WeatherClient.GetTemperature(Prague) returned 12.5.
Prague: 12.5 Celsius
[call] WeatherClient.SetUnit(Fahrenheit) succeeded.
[call] WeatherClient.GetTemperature(Lisbon) returned 69.8.
Lisbon: 69.8 Fahrenheit
[call] WeatherClient.SetUnit(Celsius) succeeded.
[call] WeatherClient.GetTemperature(Atlantis) threw ArgumentException: Unknown city 'Atlantis'. (Parameter 'city')
Atlantis: unknown city
[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
[call] WeatherClient.TryGetHumidity(Prague, 0) returned True, and changed the arguments to (Prague, 71).
Prague: humidity 71 %
[call] WeatherClient.TryGetHumidity(Lisbon, 0) returned False.
Lisbon: humidity unknown
[call] WeatherClient.GetAlerts(0, 2) returned [Lisbon: strong wind on Monday., Prague: frost on Tuesday.], and changed the arguments to (2, 2).
[call] WeatherClient.GetAlerts(2, 2) returned [Prague: heavy rain on Thursday.], and changed the arguments to (3, 2).
[call] WeatherClient.GetAlerts(3, 2) returned [].
Alerts: Lisbon: strong wind on Monday. Prague: frost on Tuesday. Prague: heavy rain on Thursday.
[call] WeatherClient.GetQuota() returned 2 requests remaining.
Available requests: 2

The RedirectToUniversalInterceptor method

RedirectToUniversalInterceptor is an extension method of IMethodResultFactory. This interface is the base interface of the method selection and of the call site in ForEachSite, so the method is available in both places, like RedirectToSynthesizedMethod and RedirectToExistingMethod. On the method selection, the method calls ForEachSite itself, because the template and its arguments depend on the intercepted method of each call site:

10// Redirects the selected calls to the IUniversalInterceptor instance that a static field or property gives.
11// The interceptorType type must implement IUniversalInterceptor. The memberName member is a static field or
12// property of this type, of type IUniversalInterceptor or AsyncLocal<IUniversalInterceptor>.
13public static IMethodInterception RedirectToUniversalInterceptor(
14    this IMethodResultFactory factory,
15    Type interceptorType,
16    string memberName )
17    => factory switch
18    {
19        // On a call site, in the delegate given to ForEachSite.
20        IMethodInterceptionSite site => RedirectSite( site, interceptorType, memberName ),
21
22        // On the method selection. The template and its arguments depend on the intercepted method, so the
23        // interception is decided for each call site.
24        IMethodSelection selection => selection.ForEachSite(
25            site => RedirectSite( site, interceptorType, memberName ) ),
26
27        _ => throw new ArgumentOutOfRangeException( nameof(factory) )
28    };
29

For each call site, RedirectSite validates the member that gives the interceptor, skips the calls that cannot be intercepted, and chooses the template as explained in the previous articles:

30private static IMethodInterception RedirectSite(
31    IMethodInterceptionSite site,
32    Type interceptorType,
33    string memberName )
34{
35    if ( !TryGetInterceptorMember( site, interceptorType, memberName, out var isAsyncLocal ) )
36    {
37        return site.Skip();
38    }
39
40    var method = site.InterceptedMethod;
41
42    if ( site.UseKind != MethodUseKind.Call )
43    {
44        // Interceptors generated from templates support only calls.
45        return Skip( site, "it is a method group, not a call" );
46    }
47
48    if ( !method.IsStatic && site.Receiver.Type is { IsReferenceType: false } )
49    {
50        // The tuple would contain a copy of the receiver, so the changes that the method makes to the receiver
51        // would be lost. The receiver of an extension method is an ordinary argument, so this rule does not
52        // apply to it.
53        return Skip( site, "its receiver is a struct, and the argument tuple would contain a copy of the receiver" );
54    }
55
56    string template;
57    IType? resultType;
58
59    if ( method.GetAsyncInfo().IsAwaitable )
60    {
61        if ( !TryChooseAsyncTemplate( method, out template, out resultType, out var reason ) )
62        {
63            return Skip( site, reason );
64        }
65    }
66    else
67    {
68        ChooseSynchronousTemplate( method, out template, out resultType );
69    }
70
71    return Redirect( site, template, resultType, interceptorType, memberName, isAsyncLocal );
72}
73

Validating the interceptor member

The arguments of RedirectToUniversalInterceptor are a type and the name of a member. A mistake in these arguments must be reported at build time, not as an exception at run time. TryGetInterceptorMember checks that the type implements IUniversalInterceptor, and that the member exists, is static, and has the type IUniversalInterceptor or AsyncLocal<IUniversalInterceptor>:

169// Checks that interceptorType implements IUniversalInterceptor and has a static member named memberName, of type
170// IUniversalInterceptor or AsyncLocal<IUniversalInterceptor>. Reports an error otherwise.
171private static bool TryGetInterceptorMember(
172    IMethodInterceptionSite site,
173    Type interceptorType,
174    string memberName,
175    out bool isAsyncLocal )
176{
177    isAsyncLocal = false;
178
179    var type = TypeFactory.GetNamedType( interceptorType );
180    var interfaceType = TypeFactory.GetNamedType( typeof(IUniversalInterceptor) );
181
182    if ( !type.IsConvertibleTo( interfaceType ) )
183    {
184        site.Diagnostics.Report( DiagnosticDefinitions.InterceptorTypeNotImplemented.WithArguments( type ) );
185
186        return false;
187    }
188
189    var member = type.FieldsAndProperties.OfName( memberName ).FirstOrDefault();
190
191    if ( member == null )
192    {
193        site.Diagnostics.Report(
194            DiagnosticDefinitions.InterceptorMemberNotFound.WithArguments( (type, memberName) ) );
195
196        return false;
197    }
198
199    if ( !member.IsStatic )
200    {
201        site.Diagnostics.Report( DiagnosticDefinitions.InterceptorMemberNotStatic.WithArguments( member ) );
202
203        return false;
204    }
205
206    var memberType = member.Type.ToNonNullable();
207
208    if ( memberType.Equals( interfaceType ) )
209    {
210        return true;
211    }
212
213    if ( memberType is INamedType { TypeArguments.Count: 1 } namedType
214         && namedType.Definition.Equals( TypeFactory.GetNamedType( typeof(AsyncLocal<>) ) )
215         && namedType.TypeArguments[0].ToNonNullable().Equals( interfaceType ) )
216    {
217        isAsyncLocal = true;
218
219        return true;
220    }
221
222    site.Diagnostics.Report(
223        DiagnosticDefinitions.InterceptorMemberTypeInvalid.WithArguments( (member, member.Type) ) );
224
225    return false;
226}
227

The errors are defined in the DiagnosticDefinitions class:

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4
5namespace Metalama.Samples.UniversalInterceptor;
6
7[CompileTime]
8internal static class DiagnosticDefinitions
9{
10    public static readonly DiagnosticDefinition<INamedType> InterceptorTypeNotImplemented = new(
11        "UNIV01",
12        Severity.Error,
13        "The type '{0}' cannot give the universal interceptor because it does not implement IUniversalInterceptor." );
14
15    public static readonly DiagnosticDefinition<(INamedType Type, string Member)> InterceptorMemberNotFound = new(
16        "UNIV02",
17        Severity.Error,
18        "The type '{0}' has no field or property named '{1}'." );
19
20    public static readonly DiagnosticDefinition<IFieldOrProperty> InterceptorMemberNotStatic = new(
21        "UNIV03",
22        Severity.Error,
23        "The member '{0}' cannot give the universal interceptor because it is not static." );
24
25    public static readonly DiagnosticDefinition<(IFieldOrProperty Member, IType Type)> InterceptorMemberTypeInvalid =
26        new(
27            "UNIV04",
28            Severity.Error,
29            "The member '{0}' cannot give the universal interceptor because its type is '{1}'. "
30            + "The type must be IUniversalInterceptor or AsyncLocal<IUniversalInterceptor>." );
31
32    public static readonly DiagnosticDefinition<(IMethod Method, string Reason)> CallNotIntercepted = new(
33        "UNIV05",
34        Severity.Warning,
35        "The call to '{0}' is not intercepted because {1}. To remove this warning, skip the call site in ForEachSite." );
36}
37

IMethodResultFactory gives no access to a diagnostic sink, so the errors are reported at each call site, through site.Diagnostics. A registration with an invalid member and no call site reports no error.

The following test registers four invalid members:

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Fabrics;
3using Metalama.Samples.UniversalInterceptor;
4using System.Runtime.CompilerServices;
5
6namespace Metalama.Samples.UniversalInterceptor.Tests.InvalidInterceptorMember;
7
8// RedirectToUniversalInterceptor reports an error at each call site when the member that gives the interceptor is not
9// valid, and the call is not intercepted.
10internal class Fabric : ProjectFabric
11{
12    public override void AmendProject( IProjectAmender amender )
13    {
14        // The type does not implement IUniversalInterceptor.
15        amender.InterceptMethods(
16            calls => calls.Type( typeof(ServiceA) )
17                .AllMethods()
18                .RedirectToUniversalInterceptor( typeof(NotAnInterceptor), nameof(NotAnInterceptor.Current) ) );
19
20        // The member does not exist.
21        amender.InterceptMethods(
22            calls => calls.Type( typeof(ServiceB) )
23                .AllMethods()
24                .RedirectToUniversalInterceptor( typeof(Interceptor), "Missing" ) );
25
26        // The member is not static.
27        amender.InterceptMethods(
28            calls => calls.Type( typeof(ServiceC) )
29                .AllMethods()
30                .RedirectToUniversalInterceptor( typeof(Interceptor), nameof(Interceptor.InstanceMember) ) );
31
32        // The member has another type.
33        amender.InterceptMethods(
34            calls => calls.Type( typeof(ServiceD) )
35                .AllMethods()
36                .RedirectToUniversalInterceptor( typeof(Interceptor), nameof(Interceptor.WrongType) ) );
37    }
38}
39
40public class NotAnInterceptor
41{
42    public static readonly NotAnInterceptor Current = new();
43}
44
45public class Interceptor : IUniversalInterceptor
46{
47    public static readonly Interceptor WrongType = new();
48
49    public IUniversalInterceptor InstanceMember => this;
50
51    public TResult Invoke<TArgs, TResult>(
52        ref TArgs args,
53        InterceptionMetadata metadata,
54        InterceptorDelegate<TArgs, TResult> proceed )
55        where TArgs : struct, ITuple
56        => proceed( ref args );
57
58    public Task<TResult> InvokeAsync<TArgs, TResult>(
59        TArgs args,
60        InterceptionMetadata metadata,
61        Func<TArgs, Task<TResult>> proceed )
62        where TArgs : struct, ITuple
63        => proceed( args );
64
65    public ValueTask<TResult> InvokeAsync<TArgs, TResult>(
66        TArgs args,
67        InterceptionMetadata metadata,
68        Func<TArgs, ValueTask<TResult>> proceed )
69        where TArgs : struct, ITuple
70        => proceed( args );
71}
72
73public class ServiceA
74{
75    public void Run() { }
76}
77
78public class ServiceB
79{
80    public void Run() { }
81}
82
83public class ServiceC
84{
85    public void Run() { }
86}
87
88public class ServiceD
89{
90    public void Run() { }
91}
92
93// <target>
94public class Program
95{
96    public void Execute()
97    {
        Error UNIV01: The type 'NotAnInterceptor' cannot give the universal interceptor because it does not implement IUniversalInterceptor.

98        new ServiceA().Run();
        Error UNIV02: The type 'Interceptor' has no field or property named 'Missing'.

99        new ServiceB().Run();
        Error UNIV03: The member 'Interceptor.InstanceMember' cannot give the universal interceptor because it is not static.

100        new ServiceC().Run();
        Error UNIV04: The member 'Interceptor.WrongType' cannot give the universal interceptor because its type is 'Interceptor'. The type must be IUniversalInterceptor or AsyncLocal<IUniversalInterceptor>.

101        new ServiceD().Run();
102    }
103}
104

The test reports the following errors, on the four calls to the Run methods:

UNIV01: The type 'NotAnInterceptor' cannot give the universal interceptor because it does not implement IUniversalInterceptor.
UNIV02: The type 'Interceptor' has no field or property named 'Missing'.
UNIV03: The member 'Interceptor.InstanceMember' cannot give the universal interceptor because it is not static.
UNIV04: The member 'Interceptor.WrongType' cannot give the universal interceptor because its type is 'Interceptor'. The type must be IUniversalInterceptor or AsyncLocal<IUniversalInterceptor>.

Skipping the calls on struct receivers

The tuple contains a copy of the receiver. When the receiver is a struct, the intercepted method would change the copy, and the changes would be lost. For instance, RequestQuota is a mutable struct, and its TryConsume method decrements the number of remaining requests:

namespace Metalama.Samples.UniversalInterceptor.Weather;

// The number of requests that the application can still send. It is a mutable struct: TryConsume changes the instance on
// which it is called.
public struct RequestQuota
{
    public RequestQuota( int remaining )
    {
        this.Remaining = remaining;
    }

    public int Remaining { get; private set; }

    public bool TryConsume()
    {
        if ( this.Remaining == 0 )
        {
            return false;
        }

        this.Remaining--;

        return true;
    }

    public override readonly string ToString() => $"{this.Remaining} requests remaining";
}

The CountAvailableRequests method of WeatherReport calls TryConsume in a loop. If the call were intercepted, quota would never change, and the loop would never end:

69    // A call on a struct receiver: TryConsume changes the quota, so the call is not intercepted.
70    public int CountAvailableRequests()
71    {
72        var quota = this._client.GetQuota();
73        var count = 0;
74
75        while ( quota.TryConsume() )
76        {
77            count++;
78        }
79
80        return count;
81    }
82

So, RedirectToUniversalInterceptor does not intercept a call on a struct receiver. It reports the warning UNIV05 and leaves the call unchanged, as the following test shows:

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Fabrics;
3using Metalama.Samples.UniversalInterceptor;
4using Metalama.Samples.UniversalInterceptor.Weather;
5using System.Runtime.CompilerServices;
6
7namespace Metalama.Samples.UniversalInterceptor.Tests.StructReceiverWarning;
8
9// Without ForEachSite, RedirectToUniversalInterceptor reports a warning for the call on the RequestQuota struct, and
10// leaves the call unchanged.




11internal class Fabric : ProjectFabric
12{
13    public override void AmendProject( IProjectAmender amender )
14        => amender.InterceptMethods(
15            calls => calls.Type( typeof(RequestQuota) )
16                .AllMethods()
17                .RedirectToUniversalInterceptor( typeof(PassThroughInterceptor), nameof(PassThroughInterceptor.Current) ) );
18}




19
20public class PassThroughInterceptor : IUniversalInterceptor
21{
22    public static readonly IUniversalInterceptor Current = new PassThroughInterceptor();
23
24    public TResult Invoke<TArgs, TResult>(
25        ref TArgs args,
26        InterceptionMetadata metadata,
27        InterceptorDelegate<TArgs, TResult> proceed )
28        where TArgs : struct, ITuple
29        => proceed( ref args );
30
31    public Task<TResult> InvokeAsync<TArgs, TResult>(
32        TArgs args,
33        InterceptionMetadata metadata,
34        Func<TArgs, Task<TResult>> proceed )
35        where TArgs : struct, ITuple
36        => proceed( args );
37
38    public ValueTask<TResult> InvokeAsync<TArgs, TResult>(
39        TArgs args,
40        InterceptionMetadata metadata,
41        Func<TArgs, ValueTask<TResult>> proceed )
42        where TArgs : struct, ITuple
43        => proceed( args );
44}
45
46// <target>
47public class Sender
48{
    Warning UNIV05: The call to 'RequestQuota.TryConsume()' is not intercepted because its receiver is a struct, and the argument tuple would contain a copy of the receiver. To remove this warning, skip the call site in ForEachSite.

49    public bool TrySend( RequestQuota quota ) => quota.TryConsume();
50}
51

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Fabrics;
3using Metalama.Samples.UniversalInterceptor;
4using Metalama.Samples.UniversalInterceptor.Weather;
5using System.Runtime.CompilerServices;
6
7namespace Metalama.Samples.UniversalInterceptor.Tests.StructReceiverWarning;
8
9// Without ForEachSite, RedirectToUniversalInterceptor reports a warning for the call on the RequestQuota struct, and
10// leaves the call unchanged.
11
12#pragma warning disable CS0067, CS8618, CS0162, CS0169, CS0414, CA1822, CA1823, IDE0051, IDE0052
13
14
15internal class Fabric : ProjectFabric
16{
17    public override void AmendProject(IProjectAmender amender) => throw new System.NotSupportedException("Compile-time-only code cannot be called at run-time.");




18}
19
20#pragma warning restore CS0067, CS8618, CS0162, CS0169, CS0414, CA1822, CA1823, IDE0051, IDE0052
21
22
23
24public class PassThroughInterceptor : IUniversalInterceptor
25{
26    public static readonly IUniversalInterceptor Current = new PassThroughInterceptor();
27
28    public TResult Invoke<TArgs, TResult>(
29        ref TArgs args,
30        InterceptionMetadata metadata,
31        InterceptorDelegate<TArgs, TResult> proceed)
32        where TArgs : struct, ITuple
33        => proceed(ref args);
34
35    public Task<TResult> InvokeAsync<TArgs, TResult>(
36        TArgs args,
37        InterceptionMetadata metadata,
38        Func<TArgs, Task<TResult>> proceed)
39        where TArgs : struct, ITuple
40        => proceed(args);
41
42    public ValueTask<TResult> InvokeAsync<TArgs, TResult>(
43        TArgs args,
44        InterceptionMetadata metadata,
45        Func<TArgs, ValueTask<TResult>> proceed)
46        where TArgs : struct, ITuple
47        => proceed(args);
48}
49
50// <target>
51public class Sender
52{
53    public bool TrySend(RequestQuota quota) => quota.TryConsume();
54}
55

The project fabric of the application skips these calls itself, with ForEachSite and site.Skip(), so the warning is not reported. It redirects the other calls with site.RedirectToUniversalInterceptor:

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Fabrics;
3using Metalama.Samples.UniversalInterceptor.Weather;
4
5namespace Metalama.Samples.UniversalInterceptor;
6
7// Redirects every call to the types of the weather library, in the whole project, to the interceptor of the current
8// asynchronous flow. CallLog.Current is an AsyncLocal<IUniversalInterceptor>, so the generated code reads its Value.
9internal class InterceptionFabric : ProjectFabric
10{
11    public override void AmendProject( IProjectAmender amender )
12    {
13        foreach ( var type in new[] { typeof(WeatherClient), typeof(RequestQuota) } )
14        {
15            amender.InterceptMethods(
16                calls => calls
17                    .Type( type )
18                    .AllMethods()
19                    .ForEachSite(
20                        site =>
21
22                            // The calls on a struct receiver are not intercepted, because the tuple would contain a
23                            // copy of the receiver.
24                            !site.InterceptedMethod.IsStatic && site.Receiver.Type is { IsReferenceType: false }
25                                ? site.Skip()
26                                : site.RedirectToUniversalInterceptor( typeof(CallLog), nameof(CallLog.Current) ) ) );
27        }
28    }
29}
30

The receiver of an extension method is an ordinary argument of the static method, so this rule does not apply to it.

Replacing the interceptor in tests

Code that calls a third-party API directly is hard to test: the tests call the real service, and they cannot choose its results. With a universal interceptor, a test can install a fake interceptor that returns canned results, without changing the code under test.

A static field of type IUniversalInterceptor is shared by all the tests. When the tests run in parallel, a test would replace the interceptor of the other tests. So, CallLog.Current is an AsyncLocal<IUniversalInterceptor>, and each test installs the interceptor for its own asynchronous flow:

8    // The instance that receives the intercepted calls in the current asynchronous flow. A value assigned in a flow is
9    // visible in this flow and in the flows that it starts, but not in the other flows. The value must be assigned
10    // before the first intercepted call, otherwise the call throws a NullReferenceException.
11    public static readonly AsyncLocal<IUniversalInterceptor> Current = new();
12

RedirectToUniversalInterceptor accepts a member of this type. The templates then read the Value property:

213// Returns the expression that gives the interceptor: the static member, or its Value property when the member is an
214// AsyncLocal<IUniversalInterceptor>.
215[CompileTime]
216private static IExpression GetInterceptor( Type interceptorType, string memberName, bool isAsyncLocal )
217{
218    var member = TypeFactory.GetNamedType( interceptorType ).FieldsAndProperties.OfName( memberName ).First();
219
220    if ( !isAsyncLocal )
221    {
222        return member;
223    }
224
225    var builder = new ExpressionBuilder();
226    builder.AppendExpression( member );
227    builder.AppendVerbatim( ".Value" );
228
229    return builder.ToExpression();
230}
231

The generated code thus contains the following line:

IUniversalInterceptor interceptor = CallLog.Current.Value;

The Value property returns the value assigned in the current asynchronous flow, or in the flow that started it. A value assigned in a flow is not visible in the other flows. The value must be assigned before the first intercepted call, otherwise the call throws a NullReferenceException. The program assigns it at the start:

1using Metalama.Samples.UniversalInterceptor;
2using Metalama.Samples.UniversalInterceptor.Weather;
3
4// Installs the interceptor for the whole program. The tests install another interceptor in their own asynchronous flow.
5CallLog.Current.Value = new CallLog();
6
7var report = new WeatherReport();
8
9Console.WriteLine( report.Describe( "Prague" ) );
10Console.WriteLine( report.Describe( "Lisbon", TemperatureUnit.Fahrenheit ) );
11Console.WriteLine( report.Describe( "Atlantis" ) );
12Console.WriteLine( await report.DescribeForecastAsync( "Prague", 3 ) );
13Console.WriteLine( report.DescribeHumidity( "Prague" ) );
14Console.WriteLine( report.DescribeHumidity( "Lisbon" ) );
15Console.WriteLine( $"Alerts: {string.Join( " ", report.GetAllAlerts() )}" );
16Console.WriteLine( $"Available requests: {report.CountAvailableRequests()}" );
17

The tests are in the Metalama.Samples.UniversalInterceptor.UnitTests project, which references the application project. The calls in WeatherReport are intercepted when the application project is built, so the tests call WeatherReport as an ordinary class.

FakeWeatherInterceptor returns canned results, by method name, and records the intercepted calls. The calls to a method without a canned result proceed to WeatherClient:

1using Metalama.Samples.UniversalInterceptor;
2using System.Collections.Concurrent;
3using System.Runtime.CompilerServices;
4
5namespace Metalama.Samples.UniversalInterceptor.UnitTests;
6
7// An interceptor that returns canned results instead of calling the weather service, and records the intercepted
8// calls. The calls to a method without a canned result proceed to the weather client.
9internal sealed class FakeWeatherInterceptor : IUniversalInterceptor
10{
11    private readonly Dictionary<string, object?> _results = new();
12    private readonly ConcurrentQueue<string> _calls = new();
13
14    public IReadOnlyCollection<string> Calls => this._calls;
15
16    public FakeWeatherInterceptor Returns( string methodName, object? result )
17    {
18        this._results[methodName] = result;
19
20        return this;
21    }
22
23    public FakeWeatherInterceptor Throws( string methodName, Exception exception )
24    {
25        this._results[methodName] = exception;
26
27        return this;
28    }
29
30    public TResult Invoke<TArgs, TResult>(
31        ref TArgs args,
32        InterceptionMetadata metadata,
33        InterceptorDelegate<TArgs, TResult> proceed )
34        where TArgs : struct, ITuple
35        => this.TryGetResult<TResult>( metadata, out var result ) ? result : proceed( ref args );
36
37    public Task<TResult> InvokeAsync<TArgs, TResult>(
38        TArgs args,
39        InterceptionMetadata metadata,
40        Func<TArgs, Task<TResult>> proceed )
41        where TArgs : struct, ITuple
42        => this.TryGetResult<TResult>( metadata, out var result ) ? Task.FromResult( result ) : proceed( args );
43
44    public ValueTask<TResult> InvokeAsync<TArgs, TResult>(
45        TArgs args,
46        InterceptionMetadata metadata,
47        Func<TArgs, ValueTask<TResult>> proceed )
48        where TArgs : struct, ITuple
49        => this.TryGetResult<TResult>( metadata, out var result ) ? ValueTask.FromResult( result ) : proceed( args );
50
51    private bool TryGetResult<TResult>( InterceptionMetadata metadata, out TResult result )
52    {
53        this._calls.Enqueue( metadata.MethodName );
54
55        if ( !this._results.TryGetValue( metadata.MethodName, out var value ) )
56        {
57            result = default!;
58
59            return false;
60        }
61
62        if ( value is Exception exception )
63        {
64            throw exception;
65        }
66
67        result = (TResult) value!;
68
69        return true;
70    }
71}
72

InterceptorScope.RunAsync runs the code of a test in a new asynchronous flow, with Task.Run, and installs the fake in this flow. The assignment is not visible in the flow of the test runner, so it does not affect the other tests:

1using Metalama.Samples.UniversalInterceptor;
2using Xunit;
3
4namespace Metalama.Samples.UniversalInterceptor.UnitTests;
5
6internal static class InterceptorScope
7{
8    // Runs the code of a test in a new asynchronous flow, in which CallLog.Current gives the interceptor. The value
9    // assigned in this flow is not visible in the flow of the test runner, or in the flows of the other tests, so the
10    // tests that run in parallel can install different interceptors.
11    public static Task RunAsync( IUniversalInterceptor interceptor, Func<Task> test )
12        => Task.Run(
13            async () =>
14            {
15                CallLog.Current.Value = interceptor;
16                await test();
17            },
18            TestContext.Current.CancellationToken );
19}
20

xUnit runs the test classes in parallel. Each test installs a fake with different results:

1using Metalama.Samples.UniversalInterceptor.Weather;
2using Xunit;
3
4namespace Metalama.Samples.UniversalInterceptor.UnitTests;
5
6// xUnit runs the test classes in parallel. Each test installs its own fake interceptor.
7public class WeatherReportTests
8{
9    [Fact]
10    public Task Describe_UsesTheCannedTemperature()
11    {
12        // The weather client does not know Oslo, so the result comes from the fake. SetUnit has no canned result, so
13        // the call proceeds to the weather client.
14        var fake = new FakeWeatherInterceptor()
15            .Returns( nameof(WeatherClient.GetTemperature), -3.5 );
16
17        return InterceptorScope.RunAsync(
18            fake,
19            () =>
20            {
21                Assert.Equal( "Oslo: -3.5 Celsius", new WeatherReport().Describe( "Oslo" ) );
22                Assert.Equal( [nameof(WeatherClient.SetUnit), nameof(WeatherClient.GetTemperature)], fake.Calls );
23
24                return Task.CompletedTask;
25            } );
26    }
27
28    [Fact]
29    public Task Describe_HandlesAnUnknownCity()
30    {
31        // The fake throws for Prague, which the weather client knows.
32        var fake = new FakeWeatherInterceptor()
33            .Throws( nameof(WeatherClient.GetTemperature), new ArgumentException( "Unknown city." ) );
34
35        return InterceptorScope.RunAsync(
36            fake,
37            () =>
38            {
39                Assert.Equal( "Prague: unknown city", new WeatherReport().Describe( "Prague" ) );
40
41                return Task.CompletedTask;
42            } );
43    }
44
45    [Fact]
46    public Task DescribeHumidity_UsesTheCannedResult()
47    {
48        // The weather client knows the humidity of Prague, but the fake returns false.
49        var fake = new FakeWeatherInterceptor()
50            .Returns( nameof(WeatherClient.TryGetHumidity), false );
51
52        return InterceptorScope.RunAsync(
53            fake,
54            () =>
55            {
56                Assert.Equal( "Prague: humidity unknown", new WeatherReport().DescribeHumidity( "Prague" ) );
57
58                return Task.CompletedTask;
59            } );
60    }
61}
62
63public class ForecastTests
64{
65    [Fact]
66    public Task DescribeForecastAsync_UsesTheCannedResults()
67    {
68        var fake = new FakeWeatherInterceptor()
69            .Returns( nameof(WeatherClient.GetForecastAsync), new Forecast( "Oslo", 2, 1.5 ) )
70            .Returns( nameof(WeatherClient.GetTemperatureAsync), 0.5 );
71
72        return InterceptorScope.RunAsync(
73            fake,
74            async () => Assert.Equal(
75                "Oslo: 0.5 now, 1.5 on average in the next 2 days",
76                await new WeatherReport().DescribeForecastAsync( "Oslo", 2 ) ) );
77    }
78
79    [Fact]
80    public Task DescribeForecastAsync_ProceedsWithoutACannedResult()
81    {
82        // The fake has no canned result, so the calls proceed to the weather client.
83        var fake = new FakeWeatherInterceptor();
84
85        return InterceptorScope.RunAsync(
86            fake,
87            async () =>
88            {
89                Assert.Equal(
90                    "Lisbon: 21 now, 22 on average in the next 4 days",
91                    await new WeatherReport().DescribeForecastAsync( "Lisbon", 4 ) );
92
93                Assert.Equal(
94                    [
95                        nameof(WeatherClient.RefreshAsync),
96                        nameof(WeatherClient.ClearCacheAsync),
97                        nameof(WeatherClient.GetForecastAsync),
98                        nameof(WeatherClient.GetTemperatureAsync)
99                    ],
100                    fake.Calls );
101            } );
102    }
103}
104
105public class IsolationTests
106{
107    // Two asynchronous flows run at the same time with different interceptors. Each flow waits until the other flow
108    // has installed its interceptor before it calls the weather client, so the two interceptors are installed at the
109    // same time, and each call still reaches the interceptor of its own flow.
110    [Fact]
111    public async Task ConcurrentFlows_UseTheirOwnInterceptor()
112    {
113        var cancellationToken = TestContext.Current.CancellationToken;
114        var firstInstalled = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously );
115        var secondInstalled = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously );
116
117        var first = RunFlowAsync( 10.0, firstInstalled, secondInstalled.Task );
118        var second = RunFlowAsync( 20.0, secondInstalled, firstInstalled.Task );
119
120        var results = await Task.WhenAll( first, second );
121
122        Assert.Equal( "Oslo: 10 Celsius", results[0] );
123        Assert.Equal( "Oslo: 20 Celsius", results[1] );
124
125        Task<string> RunFlowAsync( double temperature, TaskCompletionSource installed, Task otherInstalled )
126            => Task.Run(
127                async () =>
128                {
129                    CallLog.Current.Value = new FakeWeatherInterceptor()
130                        .Returns( nameof(WeatherClient.GetTemperature), temperature );
131
132                    installed.SetResult();
133                    await otherInstalled.WaitAsync( cancellationToken );
134
135                    return new WeatherReport().Describe( "Oslo" );
136                },
137                cancellationToken );
138    }
139}
140

The IsolationTests class shows that two flows can use different interceptors at the same time. Each flow installs its fake, then waits until the other flow has installed its fake, and only then calls WeatherReport. Both calls reach the fake of their own flow.