An interception proxy is a class that implements an interface, forwards each call to another object that implements the same interface, and lets a third party observe or alter the call. It is the mechanism behind logging decorators, caching layers, remoting stubs and test doubles.
The .NET platform offers DispatchProxy, which builds the proxy class at run time. That approach requires run-time code generation, which is not available in a trimmed or ahead-of-time compiled application, and it packs every argument into an object[], which allocates an array and boxes value types.
This example generates the proxy class at compile time instead. The aspect reads the interface from the code model and introduces a new class that implements it. Each generated method packs its arguments into a ValueTuple, which is a value type, so no boxing occurs for value-type arguments.
The interception contracts
The generated proxy calls an interceptor through the following interface. The arguments are passed through a generic TArgs type parameter constrained to struct, ITuple, so the interceptor receives the argument tuple without boxing it.
1using System.Runtime.CompilerServices;
2
3namespace Metalama.Samples.Proxy;
4
5public interface IInterceptor
6{
7 TResult Invoke<TArgs, TResult>(
8 ref TArgs args,
9 InterceptionMetadata metadata,
10 InterceptorDelegate<TArgs, TResult> proceed ) where TArgs : struct, ITuple;
11
12 Task<TResult> InvokeAsync<TArgs, TResult>(
13 TArgs args,
14 InterceptionMetadata metadata,
15 Func<TArgs, Task<TResult>> proceed ) where TArgs : struct, ITuple;
16
17 ValueTask<TResult> InvokeAsync<TArgs, TResult>(
18 TArgs args,
19 InterceptionMetadata metadata,
20 Func<TArgs, ValueTask<TResult>> proceed ) where TArgs : struct, ITuple;
21}
The synchronous overload takes the argument tuple by reference, which is what allows an interceptor to modify out and ref arguments. The callback that invokes the intercepted object therefore also takes its argument by reference, so it needs a dedicated delegate type.
1using System.Runtime.CompilerServices;
2
3namespace Metalama.Samples.Proxy;
4
5public delegate TResult InterceptorDelegate<TArgs, out TResult>( ref TArgs args )
6 where TArgs : struct, ITuple;
Each intercepted method also passes metadata that identifies it. The proxy stores one metadata instance per method in a static field, so the reflection lookup happens once per type instead of once per call.
1using System.Reflection;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4
5namespace Metalama.Samples.Proxy;
6
7public sealed class InterceptionMetadata
8{
9 public MethodInfo Method { get; }
10
11 public bool ReturnsAwaitable { get; }
12
13 public InterceptionMetadata( MethodInfo method, bool returnsAwaitable )
14 {
15 this.Method = method;
16 this.ReturnsAwaitable = returnsAwaitable;
17 }
18}
19
20[CompileTime]
21internal sealed record InterceptionMetadataInfo( IMethod Method, IField MetadataField, bool ReturnsAwaitable );
Applying the aspect
This example proxies the following interface.
1namespace Metalama.Samples.Proxy;
2
3public interface ICalculator
4{
5 int Add( int a, int b );
6
7 void Store( string name, int value );
8
9 void Clear();
10}
11
The aspect is an assembly-level attribute. It takes the interface to proxy, the name of the class to generate, and optionally the namespace to generate it in.
1using Metalama.Samples.Proxy;
2
3[assembly: GenerateProxyAspect( typeof(ICalculator), "CalculatorProxy", "Proxies" )]
4
From that single attribute, the aspect generates the following class.
1using System.Reflection;
2using Metalama.Samples.Proxy;
3
4namespace Proxies
5{
6 public class CalculatorProxy : ICalculator
7 {
8 private ICalculator _intercepted;
9 private IInterceptor _interceptor;
10 private static InterceptionMetadata _metadata1;
11 private static InterceptionMetadata _metadata2;
12 private static InterceptionMetadata _metadata3;
13
14 static CalculatorProxy()
15 {
16 CalculatorProxy._metadata1 = new InterceptionMetadata(((MethodInfo)(typeof(ICalculator).GetMethod("Add", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(int), typeof(int) }, null) ?? throw new MissingMethodException("The method 'ICalculator.Add(int, int)' could not be found using reflection."))), false);
17 CalculatorProxy._metadata2 = new InterceptionMetadata(((MethodInfo)(typeof(ICalculator).GetMethod("Store", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(string), typeof(int) }, null) ?? throw new MissingMethodException("The method 'ICalculator.Store(string, int)' could not be found using reflection."))), false);
18 CalculatorProxy._metadata3 = new InterceptionMetadata(((MethodInfo)(typeof(ICalculator).GetMethod("Clear", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null) ?? throw new MissingMethodException("The method 'ICalculator.Clear()' could not be found using reflection."))), false);
19 }
20
21 public CalculatorProxy(IInterceptor interceptor, ICalculator intercepted)
22 {
23 this._interceptor = interceptor;
24 this._intercepted = intercepted;
25 }
26 public int Add(int a, int b)
27 {
28 var args = (a, b);
29 return _interceptor.Invoke(ref args, _metadata1, Invoke);
30 int Invoke(ref (int a, int b) receivedArgs) { return this._intercepted.Add((((int a, int b))receivedArgs).a, (((int a, int b))receivedArgs).b); }
31 }
32 public void Clear()
33 {
34 var args = ValueTuple.Create();
35 _interceptor.Invoke(ref args, _metadata3, Invoke);
36 return;
37 ValueTuple Invoke(ref ValueTuple receivedArgs) { this._intercepted.Clear(); return default; }
38 }
39 public void Store(string name, int value)
40 {
41 var args = (name, value);
42 _interceptor.Invoke(ref args, _metadata2, Invoke);
43 return;
44 ValueTuple Invoke(ref (string name, int value) receivedArgs) { this._intercepted.Store((((string name, int value))receivedArgs).name, (((string name, int value))receivedArgs).value); return default; }
45 }
46 }
47}
Three details of the generated code are worth noting. The argument tuple is a local variable, so it lives on the stack. The callback that reaches the intercepted object is a local function, so it captures that local variable instead of allocating a closure object on every call. A method that takes no parameter gets an empty tuple, built by ValueTuple.Create().
Writing an interceptor
The aspect generates the plumbing, but the behaviour comes from the interceptor. The following one writes a line before and after each call.
1using System.Runtime.CompilerServices;
2
3namespace Metalama.Samples.Proxy;
4
5public sealed class LoggingInterceptor : IInterceptor
6{
7 private readonly TextWriter _writer;
8
9 public LoggingInterceptor( TextWriter writer )
10 {
11 this._writer = writer;
12 }
13
14 public TResult Invoke<TArgs, TResult>(
15 ref TArgs args,
16 InterceptionMetadata metadata,
17 InterceptorDelegate<TArgs, TResult> proceed )
18 where TArgs : struct, ITuple
19 {
20 this._writer.WriteLine( $"{Format( metadata, args )} started." );
21
22 try
23 {
24 var result = proceed( ref args );
25
26 this._writer.WriteLine( $"{Format( metadata, args )} returned {FormatResult( result )}." );
27
28 return result;
29 }
30 catch ( Exception e )
31 {
32 this._writer.WriteLine( $"{Format( metadata, args )} failed: {e.GetType().Name}." );
33
34 throw;
35 }
36 }
37
38 public async Task<TResult> InvokeAsync<TArgs, TResult>(
39 TArgs args,
40 InterceptionMetadata metadata,
41 Func<TArgs, Task<TResult>> proceed )
42 where TArgs : struct, ITuple
43 {
44 this._writer.WriteLine( $"{Format( metadata, args )} started." );
45
46 try
47 {
48 var result = await proceed( args );
49
50 this._writer.WriteLine( $"{Format( metadata, args )} returned {FormatResult( result )}." );
51
52 return result;
53 }
54 catch ( Exception e )
55 {
56 this._writer.WriteLine( $"{Format( metadata, args )} failed: {e.GetType().Name}." );
57
58 throw;
59 }
60 }
61
62 public async ValueTask<TResult> InvokeAsync<TArgs, TResult>(
63 TArgs args,
64 InterceptionMetadata metadata,
65 Func<TArgs, ValueTask<TResult>> proceed )
66 where TArgs : struct, ITuple
67 {
68 this._writer.WriteLine( $"{Format( metadata, args )} started." );
69
70 try
71 {
72 var result = await proceed( args );
73
74 this._writer.WriteLine( $"{Format( metadata, args )} returned {FormatResult( result )}." );
75
76 return result;
77 }
78 catch ( Exception e )
79 {
80 this._writer.WriteLine( $"{Format( metadata, args )} failed: {e.GetType().Name}." );
81
82 throw;
83 }
84 }
85
86 private static string Format<TArgs>( InterceptionMetadata metadata, TArgs args )
87 where TArgs : struct, ITuple
88 {
89 var arguments = new string[args.Length];
90
91 for ( var i = 0; i < args.Length; i++ )
92 {
93 arguments[i] = args[i]?.ToString() ?? "null";
94 }
95
96 return $"{metadata.Method.Name}({string.Join( ", ", arguments )})";
97 }
98
99 // A void method is projected to a ValueTuple result, which has no meaningful text representation.
100 private static string FormatResult<TResult>( TResult result )
101 => typeof(TResult) == typeof(ValueTuple) ? "void" : result?.ToString() ?? "null";
102}
103
The three methods receive the arguments as a tuple constrained to ITuple, which exposes Length and an indexer, so an interceptor can read the arguments without knowing the signature of the method it intercepts. The InterceptionMetadata parameter supplies the MethodInfo of the intercepted method.
A void method is projected onto a ValueTuple result, because the interceptor is written in terms of a return value. That is why FormatResult treats ValueTuple as a special case.
The generated proxy is then used like any hand-written class.
var calculator = new CalculatorProxy( new LoggingInterceptor( Console.Out ), new Calculator() );
var sum = calculator.Add( 2, 3 );
The project Metalama.Samples.Proxy.UnitTests executes the generated proxy and asserts this behaviour: that the return value flows back through the interceptor, that the arguments reach the intercepted object, that the interceptor observes the method name and the argument values, and that an exception thrown by the intercepted object is reported and then propagates.
Step 1. Introducing the proxy class
The aspect derives from CompilationAspect because the class it generates does not belong to any existing type. Its BuildAspect method resolves the interface, introduces the class and its fields, then introduces one method per interface method.
30public override void BuildAspect( IAspectBuilder<ICompilation> builder )
31{
32 base.BuildAspect( builder );
33
34 var interfaceType = this._interfaceType as INamedType ??
35 TypeFactory.GetNamedType( (Type) this._interfaceType );
36
37 // Add a type.
38 var type = builder.WithNamespace( this._ns )
39 .IntroduceClass(
40 this._typeName,
41 buildType: t => t.Accessibility = Accessibility.Public );
42
43 // Add a field with the intercepted object.
44 var interceptedField = type.IntroduceField(
45 "_intercepted",
46 interfaceType,
47 IntroductionScope.Instance )
48 .Declaration;
49
50 // Add a field for the interceptor.
51 var interceptorField = type.IntroduceField( "_interceptor", typeof(IInterceptor) )
52 .Declaration;
53
54 // Implement the interface.
55 type.ImplementInterface( interfaceType );
56
57 // Implement interface members.
58 var methodIndex = 0;
59
60 var metadataList = new List<InterceptionMetadataInfo>();
61
62 foreach ( var method in interfaceType.Methods )
63 {
64 methodIndex++;
65
66 var argsType = TypeFactory.CreateTupleType( method.Parameters );
67 var asyncInfo = method.GetAsyncInfo();
68 var hasByRefParameter = method.Parameters.Any( p => p.RefKind != RefKind.None );
69
70 string templateName;
71 IType resultType;
72
73 if ( !asyncInfo.IsAwaitable || hasByRefParameter )
74 {
75 if ( method.ReturnType.Equals( SpecialType.Void ) )
76 {
77 templateName = nameof(this.VoidMethodTemplate);
78 }
79 else
80 {
81 templateName = nameof(this.NonVoidMethodTemplate);
82 }
83
84 resultType = method.ReturnType;
85 }
86 else
87 {
88 var returnType = (INamedType) method.ReturnType;
89
90 switch ( returnType.Definition.SpecialType )
91 {
92 case SpecialType.Task:
93 templateName = nameof(this.TaskMethodTemplate);
94 resultType = TypeFactory.GetType( SpecialType.Void );
95
96 break;
97
98 case SpecialType.Task_T:
99 templateName = nameof(this.TaskOfTMethodTemplate);
100 resultType = returnType.TypeArguments[0];
101
102 break;
103
104 case SpecialType.ValueTask:
105 templateName = nameof(this.ValueTaskMethodTemplate);
106 resultType = TypeFactory.GetType( SpecialType.Void );
107
108 break;
109
110 case SpecialType.ValueTask_T:
111 templateName = nameof(this.ValueTaskOfTMethodTemplate);
112 resultType = returnType.TypeArguments[0];
113
114 break;
115
116 default:
117 builder.Diagnostics.Report(
118 DiagnosticDefinitions.AwaitableTypeNotSupported.WithArguments(
119 (
120 interfaceType, method) ) );
121
122 continue;
123 }
124 }
125
126 var metadataField =
127 type.IntroduceField(
128 $"_metadata{methodIndex}",
129 typeof(InterceptionMetadata),
130 buildField: field => field.IsStatic = true )
131 .Declaration;
132
133 metadataList.Add( new InterceptionMetadataInfo( method, metadataField, asyncInfo.IsAwaitable ) );
134
135 type.IntroduceMethod(
136 templateName,
137 IntroductionScope.Instance,
138 buildMethod: methodBuilder =>
139 {
140 methodBuilder.Accessibility = Accessibility.Public;
141 methodBuilder.Name = method.Name;
142 methodBuilder.ReturnType = method.ReturnType;
143
144 foreach ( var parameter in method.Parameters )
145 {
146 methodBuilder.AddParameter(
147 parameter.Name,
148 parameter.Type,
149 parameter.RefKind );
150 }
151 },
152 args:
153 new
154 {
155 TArgs = argsType,
156 TResult = resultType,
157 method,
158 interceptedField,
159 interceptorField,
160 metadataField
161 } );
162 }
163
164 // Add the constructor.
165 type.IntroduceConstructor(
166 nameof(this.Constructor),
167 buildConstructor: constructorBuilder
168 => constructorBuilder.AddParameter( "intercepted", interfaceType ),
169 args: new { interceptedField, interceptorField } );
170
171 type.AddInitializer(
172 nameof(this.StaticConstructor),
173 InitializerKind.BeforeTypeConstructor,
174 args: new { metadataList } );
175}
176The interface is stored in an object field because the aspect has two constructors. User code supplies a Type, while a fabric supplies an INamedType. BuildAspect accepts either and converts a Type with TypeFactory.GetNamedType.
builder.WithNamespace( ... ).IntroduceClass( ... ) introduces the class in the requested namespace, as explained in Introducing types, and type.ImplementInterface( interfaceType ) declares that the class implements the interface, as explained in Implementing interfaces.
For each method, TypeFactory.CreateTupleType( method.Parameters ) builds the tuple type that carries the arguments. The tuple element names and types come from the parameters, which is why the generated code reads receivedArgs.a instead of receivedArgs.Item1.
The template is then selected from the return type. There are six templates: two synchronous ones for void and non-void returns, and four asynchronous ones for Task, Task<TResult>, ValueTask and ValueTask<TResult>. The tuple type and the result type are passed to the template as compile-time type parameters, as explained in Template parameters and type parameters.
Step 2. Generating the method body
The following template generates the body of a synchronous method that returns a value. The other five templates follow the same shape.
245[Template]
246private TResult NonVoidMethodTemplate<[CompileTime] TArgs, [CompileTime] TResult>(
247 IMethod method,
248 IField interceptedField,
249 IField interceptorField,
250 IField metadataField )
251 where TArgs : struct, ITuple
252{
253 // Prepare the context.
254 var argsType = TypeFactory.CreateTupleType( method.Parameters );
255 var args = (TArgs) CreateTupleInstance( method, argsType ).Value!;
256 var argsExpression = ExpressionFactory.Capture( args );
257
258 // Get writable parameters.
259 var writableParameters = method.Parameters.Where( p =>
260 p.RefKind is RefKind.Out or RefKind.Ref )
261 .ToList();
262
263 // Invoke the interceptor.
264 if ( writableParameters.Count == 0 )
265 {
266 // We don't need a try...finally if we don't have to write back writable parameters.
267 return ((IInterceptor) interceptorField.Value!).Invoke(
268 ref args,
269 (InterceptionMetadata) metadataField.Value!,
270 Invoke );
271 }
272 else
273 {
274 try
275 {
276 return ((IInterceptor) interceptorField.Value!).Invoke(
277 ref args,
278 (InterceptionMetadata) metadataField.Value!,
279 Invoke );
280 }
281 finally
282 {
283 // Copy back parameters.
284 foreach ( var parameter in writableParameters )
285 {
286 parameter.Value = argsType.TupleElements[parameter.Index].WithObject( argsExpression ).Value;
287 }
288 }
289 }
290
291 TResult Invoke( ref TArgs receivedArgs )
292 {
293 var receivedArgsExpression = ExpressionFactory.Parse( "receivedArgs" );
294
295 var arguments =
296 method.Parameters.Select( p => argsType.TupleElements[p.Index].WithObject( receivedArgsExpression ) );
297
298 return method.WithObject( interceptedField ).Invoke( arguments )!;
299 }
300}
301The template mixes compile-time and run-time code, as explained in Writing compile-time code. The argsType, writableParameters and arguments variables are compile-time: they are evaluated while the aspect runs and leave no trace in the generated code. The call to interceptorField.Value and the local Invoke function are run-time code.
The local Invoke function reprojects the tuple elements onto the parameters of the intercepted method. ExpressionFactory.Parse( "receivedArgs" ) builds an expression that refers to the parameter of the generated local function, and argsType.TupleElements[p.Index].WithObject( ... ) builds the expression receivedArgs.<name> for each parameter.
Step 3. Writing back out and ref arguments
An out parameter cannot be read before it is assigned, so the argument tuple cannot simply copy it. The aspect substitutes a default value for every out parameter when it builds the tuple.
237private static IExpression CreateTupleInstance( IMethod method, ITupleType argsType )
238{
239 return argsType.CreateCreateInstanceExpression(
240 (IReadOnlyCollection<IExpression>) method.Parameters
241 .Select( p => p.RefKind != RefKind.Out ? p : ExpressionFactory.Default( p.Type ) )
242 .ToArray() );
243}
244After the interceptor returns, the values that the interceptor may have changed must be copied from the tuple back into the parameters. Only out and ref parameters are copied back, because in and ref readonly parameters cannot be assigned. The copy happens in a finally block, so the values are written back even when the method throws.
The template omits the try and finally blocks entirely when the method has no out or ref parameter, so the common case costs nothing. The following interface, taken from the test suite of this example, exercises all four parameter modes.
1using Metalama.Samples.Proxy;
2using Metalama.Samples.Proxy.Tests.OutRefParameters;
3
4[assembly: GenerateProxyAspect( typeof(ISomeInterface), "SomeProxy", "Metalama.Samples.Proxy.Tests" )]
5
6namespace Metalama.Samples.Proxy.Tests.OutRefParameters;
7
8public interface ISomeInterface
9{
10 void VoidMethod( out int a, ref string b, in DateTime dt, ref readonly TimeSpan ts );
11
12 int NonVoidMethod( out int a, ref string b, in DateTime dt, ref readonly TimeSpan ts );
13}
Read the limitation below before relying on this part of the aspect.
Step 4. Initializing the proxy
The instance constructor stores the interceptor and the intercepted object.
539[Template]
540public void Constructor(
541 IInterceptor interceptor,
542 IField interceptedField,
543 IField interceptorField )
544{
545 interceptorField.Value = interceptor;
546 interceptedField.Value = meta.Target.Parameters["intercepted"].Value;
547}
548The interceptor parameter is declared by the template, while the intercepted parameter is added by the buildConstructor delegate in BuildAspect. Parameters declared by the template come first in the generated signature, which is why the generated constructor reads CalculatorProxy( IInterceptor interceptor, ICalculator intercepted ).
The static constructor fills the metadata fields. It is introduced with AddInitializer and InitializerKind.BeforeTypeConstructor, so the code runs before any other static initialization of the class.
549[Template]
550private void StaticConstructor( List<InterceptionMetadataInfo> metadataList )
551{
552 foreach ( var item in metadataList )
553 {
554 item.MetadataField.Value =
555 new InterceptionMetadata( item.Method.ToMethodInfo(), item.ReturnsAwaitable );
556 }
557}The foreach loop is compile-time: it runs while the aspect executes and produces one assignment per method. item.Method.ToMethodInfo() generates the reflection expression that resolves the method, which is the typeof(ICalculator).GetMethod( ... ) call visible in the generated class.
Applying the aspect from a fabric
Adding an assembly-level attribute for each interface is repetitive. A fabric can add the aspect to a set of interfaces instead, as explained in Adding many aspects simultaneously.
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Fabrics;
4
5namespace Metalama.Samples.Proxy;
6
7[CompileTime]
8public static class FabricExtensions
9{
10 public static void GenerateStaticProxy(
11 this IQuery<INamedType> receiver,
12 Func<INamedType, string>? getProxyTypeName,
13 Func<INamedType, string>? getProxyNamespace = null )
14 {
15 receiver.WithTag( type => type )
16 .Select( type => type.Compilation )
17 .AddAspect( ( _, type ) => new GenerateProxyAspect(
18 type,
19 getProxyTypeName?.Invoke( type ) ?? type.Name.Substring( 1 ) + "Proxy",
20 getProxyNamespace?.Invoke( type ) ?? type.ContainingNamespace.FullName ) );
21 }
22
23 public static void GenerateStaticProxy(
24 this IQuery<INamedType> receiver,
25 string? proxyTypeName = null,
26 string? proxyNamespace = null )
27 {
28 receiver.WithTag( type => type )
29 .Select( type => type.Compilation )
30 .AddAspect( ( _, type ) => new GenerateProxyAspect(
31 type,
32 proxyTypeName ?? type.Name.Substring( 1 ) + "Proxy",
33 proxyNamespace ?? type.ContainingNamespace.FullName ) );
34 }
35}
The aspect targets the compilation, but the fabric selects interfaces, so the query cannot add the aspect directly. WithTag stores the selected interface, Select( type => type.Compilation ) moves the query to the compilation, and the tag is read back in the AddAspect delegate to build the aspect instance.
A fabric then needs a single statement.
1using Metalama.Framework.Fabrics;
2
3namespace Metalama.Samples.Proxy.Tests.Fabric;
4
5public class Fabric : ProjectFabric
6{
7 public override void AmendProject( IProjectAmender amender )
8 {
9 amender.SelectReflectionType( typeof(ISomeInterface) ).GenerateStaticProxy();
10 }
11}
12
13public interface ISomeInterface
14{
15 void VoidMethod( int a, string b );
16
17 int NonVoidMethod( int a, string b );
18
19 void VoidNoParamMethod();
20}
When no name is given, the extension method removes the first character of the interface name and appends Proxy, so ISomeInterface yields SomeInterfaceProxy.
Reporting unsupported return types
The aspect can generate an asynchronous method only for Task, Task<TResult>, ValueTask and ValueTask<TResult>. For any other awaitable return type it reports an error and skips the method, as explained in Reporting and suppressing diagnostics.
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4
5namespace Metalama.Samples.Proxy;
6
7[CompileTime]
8internal static class DiagnosticDefinitions
9{
10 public static readonly DiagnosticDefinition<(INamedType, IMethod)> AwaitableTypeNotSupported = new(
11 "PROXY01",
12 Severity.Error,
13 "Cannot generate a proxy for '{0}' because the return type of method '{1}' is awaitable but is not a Task or ValueTask. ",
14 "Cannot generate a proxy for the type because it contains a method with awaitable return type that is not a Task or ValueTask." );
15}
Limitations
A method with an out or ref parameter does not compile. The aspect builds the copy-back code described in step 3, and that code is correct once it is formatted, but the syntax the aspect hands to the compiler is rejected with error CS1510, reported as LAMA0611. The aspect tests do not detect this, because src/metalamaTests.json sets FormatOutput, so the test harness compiles the formatted code. Until this is fixed, restrict the interface to by-value parameters.
The aspect implements the methods of the interface, and only its methods. An interface that declares a property, an indexer or an event is not supported.
When a method has an out or ref parameter, the aspect uses the synchronous templates even if the method returns a task. The interceptor then receives the task as an ordinary return value and cannot await it, although the metadata still reports the return type as awaitable. This follows from passing the argument tuple by reference, which an asynchronous method cannot do.
The default naming rule in FabricExtensions removes the first character of the type name unconditionally. It gives the expected result for an interface named according to the usual convention, and a truncated name for any other.