diff --git a/src/Shared/SourceGeneratorAttributes.cs b/src/Shared/SourceGeneratorAttributes.cs index bdd21d0426..6bc7b2bd36 100644 --- a/src/Shared/SourceGeneratorAttributes.cs +++ b/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; } + + /// + /// Optional. Name of the generated proxy class. Defaults to the + /// interface name with the leading 'I' stripped (if present) and + /// "Proxy" appended (e.g. IFoo → FooProxy). + /// + public string? GeneratedClassName { get; set; } + } + + /// + /// When applied to a void-returning method on an interface marked with + /// , the generated proxy + /// returns instead of being + /// fire-and-forget. Has no effect on non-void methods (which are always + /// wrapped into ). + /// + [AttributeUsage(AttributeTargets.Method)] + internal sealed class GenerateCrossThreadProxyReturnTaskAttribute : Attribute + { + } } diff --git a/src/tools/DevGenerators/CrossThreadProxyGenerator.cs b/src/tools/DevGenerators/CrossThreadProxyGenerator.cs new file mode 100644 index 0000000000..53c27121ae --- /dev/null +++ b/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; + +/// +/// Generates cross-thread proxy classes for interfaces marked with +/// [GenerateCrossThreadProxy]. 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 +/// [GenerateCrossThreadProxyReturnTask]) are wrapped into +/// / Task<T>. +/// +[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(); + 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())) + { + if (bm.MethodKind != MethodKind.Ordinary) continue; + inheritedMethodSignatures.Add(MethodSignature(bm)); + } + } + + var methodsBuilder = ImmutableArray.CreateBuilder(); + 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(); + 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("// "); + 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 _marshaller;"); + sb.AppendLine(); + sb.Pad(1).Append("public ").Append(m.ClassName).Append("(") + .Append(m.InterfaceFqn).Append(" target, global::System.Action 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 wrapper. + string tcsType; + if (method.ReturnTypeFqn is null) + tcsType = "global::System.Threading.Tasks.TaskCompletionSource"; + 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 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 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); +} diff --git a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj index 526f26806f..f818772efc 100644 --- a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj +++ b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj @@ -25,6 +25,10 @@ + diff --git a/tests/Avalonia.Base.UnitTests/SourceGenerators/CrossThreadProxyGeneratorTests.cs b/tests/Avalonia.Base.UnitTests/SourceGenerators/CrossThreadProxyGeneratorTests.cs new file mode 100644 index 0000000000..795e11275a --- /dev/null +++ b/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 BaseCalls { get; } = new(); + public int Counter; + public List AsyncCalls { get; } = new(); + public Func? 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(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); + } +}