Skip to content

Conversation

@yang-xiaodong
Copy link

Summary

This PR adds a new Multicaster.SourceGenerator package that generates static proxy implementations at compile time, enabling Native AOT compilation support for Multicaster.

Motivation

The current DynamicInMemoryProxyFactory and DynamicRemoteProxyFactory use System.Reflection.Emit to generate proxy classes at runtime. This approach is incompatible with:

  • .NET Native AOT publishing
  • iOS/Android with trimming enabled
  • Blazor WebAssembly with AOT
  • MagicOnion StreamingHub in AOT scenarios

New Package: Multicaster.SourceGenerator

A Roslyn source generator that analyzes [MulticasterProxyGeneration] attributes and generates:

  1. InMemory Proxy Classes - Implement receiver interfaces and iterate over registered receivers
  2. Remote Proxy Classes - Implement receiver interfaces and serialize method calls for remote invocation
  3. Factory Implementation - Implements IInMemoryProxyFactory and IRemoteProxyFactory

Project Structure

src/Multicaster.SourceGenerator/
├── MulticasterSourceGenerator.cs      # Main generator entry point
├── CodeAnalysis/
│   └── ReceiverInterfaceCollector.cs  # Collects receiver interface metadata
├── CodeGen/
│   ├── InMemoryProxyGenerator.cs      # Generates InMemory proxy classes
│   ├── RemoteProxyGenerator.cs        # Generates Remote proxy classes
│   └── ProxyFactoryGenerator.cs       # Generates factory implementation
├── Helpers/
│   └── SourceBuilderExtensions.cs     # Code generation utilities
└── Internal/
    └── SourceBuilder.cs               # StringBuilder wrapper for code gen

Usage

1. Add the package

<PackageReference Include="Multicaster.SourceGenerator" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />

2. Define receiver interfaces

public interface IChatReceiver
{
    void OnMessage(string user, string message);
    void OnUserJoined(string user);
}

3. Create a proxy factory

[MulticasterProxyGeneration(typeof(IChatReceiver))]
public partial class MyProxyFactory { }

4. Use the generated factory

var factory = new MyProxyFactory();
var provider = new InMemoryGroupProvider(factory);

Features

  • ✅ Generates IInMemoryProxyFactory implementation
  • ✅ Generates IRemoteProxyFactory implementation
  • ✅ Supports void methods
  • ✅ Supports Task and Task<T> return types (client results)
  • ✅ Supports methods with up to 15 parameters
  • ✅ Supports CancellationToken parameters
  • ✅ Handles params array in attribute constructor
  • ✅ Proper namespace and using directive generation

Changes

New Files

  • src/Multicaster.SourceGenerator/ - Complete source generator implementation
  • src/Multicaster/MulticasterProxyGenerationAttribute.cs - Marker attribute for code generation
  • test/Multicaster.SourceGenerator.Tests/ - Unit tests for the generator
  • docs/aot-support.md - Documentation

Modified Files

  • Multicaster.sln - Added new projects
  • Directory.Packages.props - Added analyzer dependencies
  • .gitignore - Added **/generated/ for source generator output

Testing

dotnet test test/Multicaster.SourceGenerator.Tests

The tests verify:

  • Proxy generation for single and multiple receiver interfaces
  • Correct method signature generation
  • Factory method implementation
  • Handling of various method signatures (void, Task, Task)

Integration with MagicOnion

This source generator is designed to work with MagicOnion's AOT support:

[MulticasterProxyGeneration(typeof(IChatHubReceiver))]
public partial class MulticasterProxyFactory { }

// In MagicOnion configuration
builder.Services.AddMagicOnion()
    .UseStaticProxyFactory<MulticasterProxyFactory>();

Documentation

Added docs/aot-support.md with:

  • Installation instructions
  • Usage examples
  • Migration guide from dynamic proxies
  • Troubleshooting section
  • MagicOnion integration guide

Breaking Changes

None. This is an additive feature. Existing code using DynamicInMemoryProxyFactory and DynamicRemoteProxyFactory continues to work unchanged.

Checklist

  • Source generator implementation
  • Unit tests
  • Documentation
  • NuGet package configuration
  • Integration tested with MagicOnion AOT sample

yang-xiaodong and others added 4 commits January 5, 2026 22:45
- Add Multicaster.SourceGenerator project with source generator implementation
- Implement MulticasterSourceGenerator to analyze receiver interfaces and generate proxy code
- Add DiagnosticDescriptors for source generator diagnostics and error reporting
- Add ReceiverInterfaceCollector to discover and validate receiver interface types
- Add InMemoryProxyGenerator to generate in-memory proxy implementations
- Add RemoteProxyGenerator to generate remote proxy implementations
- Add ProxyFactoryGenerator to generate factory method implementations
- Add helper utilities (RoslynExtensions, StringBuilderExtensions, FNV1A32)
- Add MulticasterProxyGenerationAttribute for marking proxy factory classes
- Add comprehensive source generator tests with SourceGeneratorTests project
- Add AOT support documentation explaining usage and benefits
- Update Directory.Packages.props with Microsoft.CodeAnalysis.CSharp and PolySharp dependencies
- Update solution file to include new projects
- Enables AOT compilation scenarios (Native AOT, trimming, Blazor WASM) by generating proxies at compile time instead of runtime
Updated Multicaster.SourceGenerator.csproj to:
- Add required NuGet package references for source generation (Microsoft.CodeAnalysis.CSharp, Microsoft.CodeAnalysis.Analyzers, PolySharp) with PrivateAssets="all".
- Include the built analyzer DLL in the NuGet package under analyzers/dotnet/cs.
- Change file encoding (likely to UTF-8 with BOM).
These changes improve packaging and dependency management for the source generator.
- Add generated/ directory to gitignore to exclude source generator output
- Prevents accidental commits of auto-generated code files
- Keeps repository clean by ignoring build artifacts from source generators
…uide

- Add MagicOnion StreamingHub to list of supported AOT scenarios
- Document project reference setup for source generator during development
- Add comprehensive MagicOnion integration example with StreamingHub receiver
- Include configuration example for static proxy factory with MagicOnion
- Add reference to MagicOnion AOT Sample for complete implementation details
- Improve documentation completeness for AOT adoption and integration patterns
Copilot AI review requested due to automatic review settings January 6, 2026 13:01
@yang-xiaodong yang-xiaodong requested a review from mayuki as a code owner January 6, 2026 13:01
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new Multicaster.SourceGenerator package that enables AOT (Ahead-of-Time) compilation support for Multicaster by generating static proxy implementations at compile time, replacing runtime System.Reflection.Emit usage.

Key Changes:

  • New source generator that analyzes [MulticasterProxyGeneration] attributes and generates both InMemory and Remote proxy implementations
  • New marker attribute for triggering source generation at compile time
  • Comprehensive test suite covering proxy generation and invocation scenarios

Reviewed changes

Copilot reviewed 19 out of 20 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/Multicaster/MulticasterProxyGenerationAttribute.cs Marker attribute for source generation, accepts params array of receiver interface types
src/Multicaster.SourceGenerator/MulticasterSourceGenerator.cs Main incremental generator entry point using Roslyn's ForAttributeWithMetadataName
src/Multicaster.SourceGenerator/CodeAnalysis/ReceiverInterfaceCollector.cs Collects interface metadata including methods, parameters, and return types
src/Multicaster.SourceGenerator/CodeAnalysis/ReceiverInterfaceInfo.cs Data models representing receiver interfaces and their methods
src/Multicaster.SourceGenerator/CodeAnalysis/DiagnosticDescriptors.cs Defines diagnostic errors and warnings for validation
src/Multicaster.SourceGenerator/CodeGen/ProxyFactoryGenerator.cs Generates factory class implementing both IInMemoryProxyFactory and IRemoteProxyFactory
src/Multicaster.SourceGenerator/CodeGen/InMemoryProxyGenerator.cs Generates in-memory proxy classes that iterate over receivers
src/Multicaster.SourceGenerator/CodeGen/RemoteProxyGenerator.cs Generates remote proxy classes that serialize method calls
src/Multicaster.SourceGenerator/Helpers/RoslynExtensions.cs Helper extensions for Roslyn symbol operations
src/Multicaster.SourceGenerator/Helpers/StringBuilderExtensions.cs StringBuilder utilities for code generation
src/Multicaster.SourceGenerator/Internal/FNV1A32.cs Hash function for generating method IDs
test/Multicaster.SourceGenerator.Tests/GeneratedProxyFactory.cs Test factory annotated with [MulticasterProxyGeneration] attribute
test/Multicaster.SourceGenerator.Tests/TestReceivers.cs Test interface definitions for chat, game, and async client results
test/Multicaster.SourceGenerator.Tests/SourceGeneratorTests.cs Unit tests verifying proxy creation and invocation
docs/aot-support.md Comprehensive documentation including usage, migration guide, and MagicOnion integration
Multicaster.sln Added new source generator and test projects to solution
Directory.Packages.props Added Roslyn analyzer dependencies

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +36 to +61
public static readonly DiagnosticDescriptor NoReceiverInterfacesFound = new(
id: "MULT004",
title: "No receiver interfaces found",
messageFormat: "No receiver interfaces were specified or found for proxy generation",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true
);

public static readonly DiagnosticDescriptor NoReceiverInterfacesSpecified = new(
id: "MULT006",
title: "No receiver interfaces specified",
messageFormat: "The [MulticasterProxyGeneration] attribute must specify at least one receiver interface type",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true
);

public static readonly DiagnosticDescriptor TooManyParameters = new(
id: "MULT005",
title: "Too many parameters",
messageFormat: "The method '{0}' has {1} parameters, but the maximum supported is 15",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true
);
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diagnostic descriptors TooManyParameters and NoReceiverInterfacesFound are defined but never used in the codebase. If they are intended for future use, consider removing them for now to reduce code clutter, or implement the validation logic that would use them. For TooManyParameters specifically, the documentation mentions a 15 parameter limit but no validation enforces this constraint.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +61
public static readonly DiagnosticDescriptor NoReceiverInterfacesSpecified = new(
id: "MULT006",
title: "No receiver interfaces specified",
messageFormat: "The [MulticasterProxyGeneration] attribute must specify at least one receiver interface type",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true
);

public static readonly DiagnosticDescriptor TooManyParameters = new(
id: "MULT005",
title: "Too many parameters",
messageFormat: "The method '{0}' has {1} parameters, but the maximum supported is 15",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true
);
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diagnostic IDs are not in sequential order (MULT001, MULT002, MULT003, MULT004, MULT006, MULT005). Consider reordering MULT005 (TooManyParameters) to come before MULT006 (NoReceiverInterfacesSpecified) for better maintainability and consistency.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +33
public static StringBuilder AppendLineWithFormat(this StringBuilder sb, [InterpolatedStringHandlerArgument(nameof(sb))] StringBuilderInterpolatedStringHandler s)
{
sb.AppendLine();
return sb;
}

[InterpolatedStringHandler]
public readonly ref struct StringBuilderInterpolatedStringHandler
{
readonly StringBuilder sb;

public StringBuilderInterpolatedStringHandler(int literalLength, int formattedCount, StringBuilder sb)
{
this.sb = sb;
}

public void AppendLiteral(string s)
{
sb.Append(s);
}

public void AppendFormatted<T>(T t)
{
sb.Append(t);
}
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AppendLineWithFormat method and its associated StringBuilderInterpolatedStringHandler are defined but never used in the codebase. Consider removing these to reduce unnecessary code, or use them in the code generation logic if they were intended to simplify string interpolation.

Suggested change
public static StringBuilder AppendLineWithFormat(this StringBuilder sb, [InterpolatedStringHandlerArgument(nameof(sb))] StringBuilderInterpolatedStringHandler s)
{
sb.AppendLine();
return sb;
}
[InterpolatedStringHandler]
public readonly ref struct StringBuilderInterpolatedStringHandler
{
readonly StringBuilder sb;
public StringBuilderInterpolatedStringHandler(int literalLength, int formattedCount, StringBuilder sb)
{
this.sb = sb;
}
public void AppendLiteral(string s)
{
sb.Append(s);
}
public void AppendFormatted<T>(T t)
{
sb.Append(t);
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +92
static string GetSafeTypeName(string typeName)
{
return typeName.Replace(".", "_").Replace("<", "_").Replace(">", "_").Replace(",", "_");
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GetSafeTypeName method is duplicated across InMemoryProxyGenerator, RemoteProxyGenerator, and ProxyFactoryGenerator. Consider extracting this to a shared helper class to eliminate code duplication and ensure consistent behavior across all generators.

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +26
public static AttributeData? FindAttributeShortName(this IEnumerable<AttributeData> attributeDataList, string typeName)
{
return attributeDataList.FirstOrDefault(x => x.AttributeClass?.Name == typeName);
}

public static bool ApproximatelyEqual(this ITypeSymbol? left, ITypeSymbol? right)
{
if (left == null && right == null) return true;
if (left == null || right == null) return false;

if (left is IErrorTypeSymbol || right is IErrorTypeSymbol)
{
return left.ToDisplayString() == right.ToDisplayString();
}
else
{
return SymbolEqualityComparer.Default.Equals(left, right);
}
}

Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extension methods FindAttributeShortName and ApproximatelyEqual are defined but never used in the codebase. Consider removing these unused methods to reduce code clutter, or use them if they were intended for specific logic.

Suggested change
public static AttributeData? FindAttributeShortName(this IEnumerable<AttributeData> attributeDataList, string typeName)
{
return attributeDataList.FirstOrDefault(x => x.AttributeClass?.Name == typeName);
}
public static bool ApproximatelyEqual(this ITypeSymbol? left, ITypeSymbol? right)
{
if (left == null && right == null) return true;
if (left == null || right == null) return false;
if (left is IErrorTypeSymbol || right is IErrorTypeSymbol)
{
return left.ToDisplayString() == right.ToDisplayString();
}
else
{
return SymbolEqualityComparer.Default.Equals(left, right);
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +75 to +95
static IEnumerable<IMethodSymbol> GetAllInterfaceMethods(INamedTypeSymbol interfaceType)
{
foreach (var member in interfaceType.GetMembers())
{
if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary)
{
yield return method;
}
}

foreach (var baseInterface in interfaceType.AllInterfaces)
{
foreach (var member in baseInterface.GetMembers())
{
if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary)
{
yield return method;
}
}
}
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method GetAllInterfaceMethods may return duplicate methods when an interface inherits from base interfaces. For example, if two base interfaces declare the same method, or if a method is redeclared in the derived interface, this will yield the same method multiple times, leading to duplicate method implementations in the generated proxy class. Consider deduplicating methods by their signature or using SymbolEqualityComparer to track already-seen methods.

Copilot uses AI. Check for mistakes.
Comment on lines +77 to +83
foreach (var member in interfaceType.GetMembers())
{
if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary)
{
yield return method;
}
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +93
foreach (var member in baseInterface.GetMembers())
{
if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary)
{
yield return method;
}
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +101
foreach (var item in arg.Values)
{
if (item.Value is INamedTypeSymbol typeSymbol)
{
types.Add(typeSymbol);
}
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +24
if (left is IErrorTypeSymbol || right is IErrorTypeSymbol)
{
return left.ToDisplayString() == right.ToDisplayString();
}
else
{
return SymbolEqualityComparer.Default.Equals(left, right);
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both branches of this 'if' statement return - consider using '?' to express intent better.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant