Browse Source

Roslyn generator for generating cross-thread call proxies (#21384)

pull/21394/head
Nikita Tsukanov 5 months ago
committed by GitHub
parent
commit
ed836146d6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 32
      src/Shared/SourceGeneratorAttributes.cs
  2. 381
      src/tools/DevGenerators/CrossThreadProxyGenerator.cs
  3. 4
      tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj
  4. 160
      tests/Avalonia.Base.UnitTests/SourceGenerators/CrossThreadProxyGeneratorTests.cs

32
src/Shared/SourceGeneratorAttributes.cs

@ -51,4 +51,36 @@ namespace Avalonia.SourceGenerator
internal sealed class GenerateEnumValueListAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Interface, Inherited = false, AllowMultiple = false)]
internal sealed class GenerateCrossThreadProxyAttribute : Attribute
{
public GenerateCrossThreadProxyAttribute(Type priorityType, string defaultPriorityExpression)
{
PriorityType = priorityType;
DefaultPriorityExpression = defaultPriorityExpression;
}
public Type PriorityType { get; }
public string DefaultPriorityExpression { get; }
/// <summary>
/// Optional. Name of the generated proxy class. Defaults to the
/// interface name with the leading 'I' stripped (if present) and
/// "Proxy" appended (e.g. <c>IFoo</c> → <c>FooProxy</c>).
/// </summary>
public string? GeneratedClassName { get; set; }
}
/// <summary>
/// When applied to a void-returning method on an interface marked with
/// <see cref="GenerateCrossThreadProxyAttribute"/>, the generated proxy
/// returns <see cref="System.Threading.Tasks.Task"/> instead of being
/// fire-and-forget. Has no effect on non-void methods (which are always
/// wrapped into <see cref="System.Threading.Tasks.Task{TResult}"/>).
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
internal sealed class GenerateCrossThreadProxyReturnTaskAttribute : Attribute
{
}
}

381
src/tools/DevGenerators/CrossThreadProxyGenerator.cs

@ -0,0 +1,381 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Generator;
using Microsoft.CodeAnalysis;
namespace DevGenerators;
/// <summary>
/// Generates cross-thread proxy classes for interfaces marked with
/// <c>[GenerateCrossThreadProxy]</c>. The generated proxy mirrors the source
/// interface but routes every call through a user-supplied marshaller
/// (e.g. a dispatcher post). Void methods become fire-and-forget; non-void
/// methods (and methods explicitly tagged with
/// <c>[GenerateCrossThreadProxyReturnTask]</c>) are wrapped into
/// <see cref="System.Threading.Tasks.Task"/> / <c>Task&lt;T&gt;</c>.
/// </summary>
[Generator(LanguageNames.CSharp)]
public sealed class CrossThreadProxyGenerator : IIncrementalGenerator
{
private const string ProxyAttributeName = "Avalonia.SourceGenerator.GenerateCrossThreadProxyAttribute";
private const string ReturnTaskAttributeName = "global::Avalonia.SourceGenerator.GenerateCrossThreadProxyReturnTaskAttribute";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var proxies = context.SyntaxProvider
.ForAttributeWithMetadataName(
ProxyAttributeName,
static (node, _) => node is Microsoft.CodeAnalysis.CSharp.Syntax.InterfaceDeclarationSyntax,
static (ctx, _) => Extract(ctx))
.Where(static x => x is not null)
.Select(static (x, _) => x!);
context.RegisterSourceOutput(proxies, static (spc, model) =>
{
var source = Render(model);
spc.AddSource(model.HintName, source);
});
}
private static ProxyModel? Extract(GeneratorAttributeSyntaxContext ctx)
{
if (ctx.TargetSymbol is not INamedTypeSymbol iface || iface.TypeKind != TypeKind.Interface)
return null;
var attr = ctx.Attributes.FirstOrDefault();
if (attr is null || attr.ConstructorArguments.Length < 2)
return null;
var priorityTypeArg = attr.ConstructorArguments[0];
var defaultExprArg = attr.ConstructorArguments[1];
if (priorityTypeArg.Value is not INamedTypeSymbol priorityType
|| defaultExprArg.Value is not string defaultExpr)
return null;
string? generatedClassName = null;
foreach (var named in attr.NamedArguments)
{
if (named.Key == "GeneratedClassName" && named.Value.Value is string s)
generatedClassName = s;
}
var className = generatedClassName ?? DefaultProxyName(iface.Name);
var ns = iface.ContainingNamespace.IsGlobalNamespace
? null
: iface.ContainingNamespace.ToDisplayString();
var ifaceFqn = iface.GetFullyQualifiedName();
var priorityFqn = priorityType.GetFullyQualifiedName();
// Detect a single direct base interface that also carries the proxy attribute;
// we will inherit from its generated proxy class instead of re-emitting members.
string? baseProxyFqn = null;
var inheritedMethodSignatures = new HashSet<string>();
foreach (var baseIface in iface.Interfaces)
{
var baseAttr = baseIface.GetAttributes().FirstOrDefault(a =>
a.AttributeClass?.ToDisplayString() == ProxyAttributeName);
if (baseAttr is null)
continue;
if (baseProxyFqn is not null)
return null; // multiple proxied bases unsupported
string? baseGenerated = null;
foreach (var na in baseAttr.NamedArguments)
{
if (na.Key == "GeneratedClassName" && na.Value.Value is string s)
baseGenerated = s;
}
var baseClassName = baseGenerated ?? DefaultProxyName(baseIface.Name);
var baseNs = baseIface.ContainingNamespace.IsGlobalNamespace
? null
: baseIface.ContainingNamespace.ToDisplayString();
baseProxyFqn = "global::" + (baseNs is null ? baseClassName : baseNs + "." + baseClassName);
foreach (var bm in baseIface.AllInterfaces.Concat(new[] { baseIface })
.SelectMany(i => i.GetMembers().OfType<IMethodSymbol>()))
{
if (bm.MethodKind != MethodKind.Ordinary) continue;
inheritedMethodSignatures.Add(MethodSignature(bm));
}
}
var methodsBuilder = ImmutableArray.CreateBuilder<MethodModel>();
foreach (var member in iface.GetMembers())
{
if (member is not IMethodSymbol m || m.MethodKind != MethodKind.Ordinary)
continue;
if (m.IsGenericMethod)
continue;
if (inheritedMethodSignatures.Contains(MethodSignature(m)))
continue;
var hasReturnTaskAttr = m.HasAttributeWithFullyQualifiedName(ReturnTaskAttributeName);
var returnsVoid = m.ReturnsVoid;
var wantTask = hasReturnTaskAttr || !returnsVoid;
var paramsBuilder = ImmutableArray.CreateBuilder<ParamModel>();
var skip = false;
foreach (var p in m.Parameters)
{
if (p.RefKind != RefKind.None)
{
skip = true;
break;
}
paramsBuilder.Add(new ParamModel(p.Name, p.Type.GetFullyQualifiedName()));
}
if (skip)
continue;
methodsBuilder.Add(new MethodModel(
Name: m.Name,
ReturnTypeFqn: returnsVoid ? null : m.ReturnType.GetFullyQualifiedName(),
WrapInTask: wantTask,
Parameters: paramsBuilder.ToImmutable()));
}
var hintName = (ns is null ? className : ns + "." + className) + ".g.cs";
return new ProxyModel(
HintName: hintName,
Namespace: ns,
ClassName: className,
InterfaceFqn: ifaceFqn,
PriorityFqn: priorityFqn,
DefaultPriorityExpression: defaultExpr,
BaseProxyFqn: baseProxyFqn,
Methods: methodsBuilder.ToImmutable());
}
private static string MethodSignature(IMethodSymbol m)
{
var sb = new StringBuilder();
sb.Append(m.Name).Append('(');
for (var i = 0; i < m.Parameters.Length; i++)
{
if (i > 0) sb.Append(',');
sb.Append(m.Parameters[i].Type.GetFullyQualifiedName());
}
sb.Append(')');
return sb.ToString();
}
private static string DefaultProxyName(string ifaceName)
{
if (ifaceName.Length > 1 && ifaceName[0] == 'I' && char.IsUpper(ifaceName[1]))
return ifaceName.Substring(1) + "Proxy";
return ifaceName + "Proxy";
}
private static string Render(ProxyModel m)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System;");
sb.AppendLine("using System.Threading.Tasks;");
if (m.Namespace is not null)
{
sb.Append("namespace ").Append(m.Namespace).AppendLine(";");
}
sb.Append("internal class ").Append(m.ClassName);
var canImplementInterface = m.Methods.All(mm => !mm.WrapInTask);
if (m.BaseProxyFqn is not null)
{
sb.Append(" : ").Append(m.BaseProxyFqn);
if (canImplementInterface)
sb.Append(", ").Append(m.InterfaceFqn);
}
else if (canImplementInterface)
{
sb.Append(" : ").Append(m.InterfaceFqn);
}
sb.AppendLine();
sb.AppendLine("{");
sb.Pad(1).Append("private readonly ").Append(m.InterfaceFqn).AppendLine(" _target;");
sb.Pad(1).Append("private readonly global::System.Action<global::System.Action, ")
.Append(m.PriorityFqn).AppendLine("> _marshaller;");
sb.AppendLine();
sb.Pad(1).Append("public ").Append(m.ClassName).Append("(")
.Append(m.InterfaceFqn).Append(" target, global::System.Action<global::System.Action, ")
.Append(m.PriorityFqn).Append("> marshaller)");
if (m.BaseProxyFqn is not null)
sb.Append(" : base(target, marshaller)");
sb.AppendLine();
sb.Pad(1).AppendLine("{");
sb.Pad(2).AppendLine("_target = target;");
sb.Pad(2).AppendLine("_marshaller = marshaller;");
sb.Pad(1).AppendLine("}");
// Worker-side accessor for identity-passing scenarios (e.g. IWXdgTopLevel.SetParent
// takes another IWXdgTopLevel which on the worker side must be unwrapped to the real
// target so we can dereference its protocol object). Hide it from base classes via
// 'new' to avoid ambiguity in inheritance chains.
sb.AppendLine();
sb.Pad(1).Append("internal ");
if (m.BaseProxyFqn is not null)
sb.Append("new ");
sb.Append(m.InterfaceFqn).AppendLine(" ProxyTarget => _target;");
foreach (var method in m.Methods)
{
sb.AppendLine();
RenderMethod(sb, m, method);
}
sb.AppendLine("}");
return sb.ToString();
}
private static void RenderMethod(StringBuilder sb, ProxyModel m, MethodModel method)
{
var paramListNoPriority = string.Join(", ",
method.Parameters.Select(p => p.TypeFqn + " " + p.Name));
var paramListWithPriority = paramListNoPriority.Length > 0
? paramListNoPriority + ", " + m.PriorityFqn + " priority"
: m.PriorityFqn + " priority";
var argList = string.Join(", ", method.Parameters.Select(p => p.Name));
var argListPlusDefaultPriority = argList.Length > 0
? argList + ", " + m.DefaultPriorityExpression
: m.DefaultPriorityExpression;
string returnType;
if (!method.WrapInTask)
returnType = "void";
else if (method.ReturnTypeFqn is null)
returnType = "global::System.Threading.Tasks.Task";
else
returnType = "global::System.Threading.Tasks.Task<" + method.ReturnTypeFqn + ">";
// Convenience overload — uses the configured default priority.
sb.Pad(1).Append("public ").Append(returnType).Append(' ').Append(method.Name)
.Append('(').Append(paramListNoPriority).Append(") => ")
.Append(method.Name).Append('(').Append(argListPlusDefaultPriority).AppendLine(");");
sb.AppendLine();
sb.Pad(1).Append("public ").Append(returnType).Append(' ').Append(method.Name)
.Append('(').Append(paramListWithPriority).AppendLine(")");
sb.Pad(1).AppendLine("{");
if (!method.WrapInTask)
{
// Fire-and-forget void.
sb.Pad(2).Append("_marshaller(() => _target.").Append(method.Name)
.Append('(').Append(argList).AppendLine("), priority);");
}
else
{
// Task / Task<T> wrapper.
string tcsType;
if (method.ReturnTypeFqn is null)
tcsType = "global::System.Threading.Tasks.TaskCompletionSource<bool>";
else
tcsType = "global::System.Threading.Tasks.TaskCompletionSource<" + method.ReturnTypeFqn + ">";
sb.Pad(2).Append("var tcs = new ").Append(tcsType)
.AppendLine("(global::System.Threading.Tasks.TaskCreationOptions.RunContinuationsAsynchronously);");
sb.Pad(2).AppendLine("_marshaller(() =>");
sb.Pad(2).AppendLine("{");
sb.Pad(3).AppendLine("try");
sb.Pad(3).AppendLine("{");
if (method.ReturnTypeFqn is null)
{
sb.Pad(4).Append("_target.").Append(method.Name).Append('(').Append(argList).AppendLine(");");
sb.Pad(4).AppendLine("tcs.TrySetResult(true);");
}
else
{
sb.Pad(4).Append("var __result = _target.").Append(method.Name)
.Append('(').Append(argList).AppendLine(");");
sb.Pad(4).AppendLine("tcs.TrySetResult(__result);");
}
sb.Pad(3).AppendLine("}");
sb.Pad(3).AppendLine("catch (global::System.Exception __ex)");
sb.Pad(3).AppendLine("{");
sb.Pad(4).AppendLine("tcs.TrySetException(__ex);");
sb.Pad(3).AppendLine("}");
sb.Pad(2).AppendLine("}, priority);");
sb.Pad(2).AppendLine("return tcs.Task;");
}
sb.Pad(1).AppendLine("}");
}
// ---- equatable models ----
private sealed record ProxyModel(
string HintName,
string? Namespace,
string ClassName,
string InterfaceFqn,
string PriorityFqn,
string DefaultPriorityExpression,
string? BaseProxyFqn,
ImmutableArray<MethodModel> Methods)
{
public bool Equals(ProxyModel? other)
{
if (other is null) return false;
return HintName == other.HintName
&& Namespace == other.Namespace
&& ClassName == other.ClassName
&& InterfaceFqn == other.InterfaceFqn
&& PriorityFqn == other.PriorityFqn
&& DefaultPriorityExpression == other.DefaultPriorityExpression
&& BaseProxyFqn == other.BaseProxyFqn
&& Methods.SequenceEqual(other.Methods);
}
public override int GetHashCode()
{
unchecked
{
var h = 17;
h = h * 31 + (HintName?.GetHashCode() ?? 0);
h = h * 31 + (Namespace?.GetHashCode() ?? 0);
h = h * 31 + (ClassName?.GetHashCode() ?? 0);
h = h * 31 + (InterfaceFqn?.GetHashCode() ?? 0);
h = h * 31 + (PriorityFqn?.GetHashCode() ?? 0);
h = h * 31 + (DefaultPriorityExpression?.GetHashCode() ?? 0);
h = h * 31 + (BaseProxyFqn?.GetHashCode() ?? 0);
foreach (var m in Methods) h = h * 31 + m.GetHashCode();
return h;
}
}
}
private sealed record MethodModel(
string Name,
string? ReturnTypeFqn,
bool WrapInTask,
ImmutableArray<ParamModel> Parameters)
{
public bool Equals(MethodModel? other)
{
if (other is null) return false;
return Name == other.Name
&& ReturnTypeFqn == other.ReturnTypeFqn
&& WrapInTask == other.WrapInTask
&& Parameters.SequenceEqual(other.Parameters);
}
public override int GetHashCode()
{
unchecked
{
var h = 17;
h = h * 31 + (Name?.GetHashCode() ?? 0);
h = h * 31 + (ReturnTypeFqn?.GetHashCode() ?? 0);
h = h * 31 + WrapInTask.GetHashCode();
foreach (var p in Parameters) h = h * 31 + p.GetHashCode();
return h;
}
}
}
private sealed record ParamModel(string Name, string TypeFqn);
}

4
tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj

@ -25,6 +25,10 @@
<ProjectReference Include="..\..\src\Avalonia.Base\Avalonia.Base.csproj" />
<ProjectReference Include="..\..\src\Markup\Avalonia.Markup.Xaml.Loader\Avalonia.Markup.Xaml.Loader.csproj" />
<ProjectReference Include="..\Avalonia.UnitTests\Avalonia.UnitTests.csproj" />
<ProjectReference Include="..\..\src\tools\DevGenerators\DevGenerators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"
PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<Service Include="{82a7f48d-3b50-4b1e-b82e-3ada8210c358}" />

160
tests/Avalonia.Base.UnitTests/SourceGenerators/CrossThreadProxyGeneratorTests.cs

@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Avalonia.SourceGenerator;
using Xunit;
namespace Avalonia.Base.UnitTests.SourceGenerators;
public class CrossThreadProxyGeneratorTests
{
public enum TestPriority { Low, Normal, High }
[GenerateCrossThreadProxy(typeof(TestPriority), "Avalonia.Base.UnitTests.SourceGenerators.CrossThreadProxyGeneratorTests.TestPriority.Normal")]
public interface IBaseProxied
{
void BaseFireAndForget(int x);
}
[GenerateCrossThreadProxy(typeof(TestPriority), "Avalonia.Base.UnitTests.SourceGenerators.CrossThreadProxyGeneratorTests.TestPriority.Normal")]
public interface IDerivedProxied : IBaseProxied
{
void Increment();
int GetValue();
[GenerateCrossThreadProxyReturnTask]
void AsyncFireAndForget(string s);
}
private sealed class Target : IDerivedProxied
{
public List<int> BaseCalls { get; } = new();
public int Counter;
public List<string> AsyncCalls { get; } = new();
public Func<int>? GetValueImpl;
public void BaseFireAndForget(int x) => BaseCalls.Add(x);
public void Increment() => Counter++;
public int GetValue() => GetValueImpl?.Invoke() ?? Counter;
public void AsyncFireAndForget(string s) => AsyncCalls.Add(s);
}
private sealed class QueueMarshaller
{
public readonly Queue<(Action action, TestPriority priority)> Queue = new();
public void Post(Action a, TestPriority p) => Queue.Enqueue((a, p));
public void DrainAll()
{
while (Queue.Count > 0) Queue.Dequeue().action();
}
}
[Fact]
public void Fire_and_forget_void_routes_through_marshaller_with_default_priority()
{
var t = new Target();
var m = new QueueMarshaller();
var proxy = new DerivedProxiedProxy(t, m.Post);
proxy.Increment();
Assert.Single(m.Queue);
Assert.Equal(TestPriority.Normal, m.Queue.Peek().priority);
Assert.Equal(0, t.Counter);
m.DrainAll();
Assert.Equal(1, t.Counter);
}
[Fact]
public void Explicit_priority_overload_is_used()
{
var t = new Target();
var m = new QueueMarshaller();
var proxy = new DerivedProxiedProxy(t, m.Post);
proxy.Increment(TestPriority.High);
Assert.Equal(TestPriority.High, m.Queue.Peek().priority);
}
[Fact]
public async Task NonVoid_returns_Task_completed_after_marshaller_runs()
{
var t = new Target { Counter = 42 };
var m = new QueueMarshaller();
var proxy = new DerivedProxiedProxy(t, m.Post);
var task = proxy.GetValue();
Assert.False(task.IsCompleted);
m.DrainAll();
Assert.True(task.IsCompleted);
Assert.Equal(42, await task);
}
[Fact]
public void Exception_in_target_propagates_to_Task()
{
var t = new Target { GetValueImpl = () => throw new InvalidOperationException("boom") };
var m = new QueueMarshaller();
var proxy = new DerivedProxiedProxy(t, m.Post);
var task = proxy.GetValue();
m.DrainAll();
Assert.True(task.IsFaulted);
Assert.IsType<InvalidOperationException>(task.Exception!.InnerException);
}
[Fact]
public void Void_method_with_ReturnTask_attribute_returns_Task()
{
var t = new Target();
var m = new QueueMarshaller();
var proxy = new DerivedProxiedProxy(t, m.Post);
Task task = proxy.AsyncFireAndForget("hi");
Assert.False(task.IsCompleted);
m.DrainAll();
Assert.True(task.IsCompleted);
Assert.Equal(new[] { "hi" }, t.AsyncCalls);
}
[Fact]
public void Inherited_method_routes_through_base_proxy()
{
var t = new Target();
var m = new QueueMarshaller();
var proxy = new DerivedProxiedProxy(t, m.Post);
proxy.BaseFireAndForget(7);
Assert.Single(m.Queue);
m.DrainAll();
Assert.Equal(new[] { 7 }, t.BaseCalls);
}
[Fact]
public void Derived_proxy_is_assignable_to_base_proxy()
{
var t = new Target();
var m = new QueueMarshaller();
BaseProxiedProxy proxy = new DerivedProxiedProxy(t, m.Post);
proxy.BaseFireAndForget(3);
m.DrainAll();
Assert.Equal(new[] { 3 }, t.BaseCalls);
}
[Fact]
public void Marshaller_is_not_invoked_synchronously_during_proxy_call()
{
var t = new Target();
var m = new QueueMarshaller();
var proxy = new DerivedProxiedProxy(t, m.Post);
proxy.Increment();
proxy.Increment();
proxy.Increment();
Assert.Equal(0, t.Counter);
Assert.Equal(3, m.Queue.Count);
}
}
Loading…
Cancel
Save