dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.3k stars 9.97k forks source link

SignalR: Using IAsyncEnumerable<T> and ChannelReader<T> with ValueTypes in native AOT #56179

Closed eerhardt closed 2 months ago

eerhardt commented 3 months ago

With https://github.com/dotnet/aspnetcore/pull/56079, native AOT support for SignalR client was added. But one scenario that isn't supported is to use "streaming" APIs (IAsyncEnumerable<T> and ChannelReader<T>) with ValueTypes. In order to work with IAsyncEnumerable<T> and ChannelReader<T> we either need to "jump" to a <T> generic method (which is what it does today, and not supported with native AOT because it can't generate the code up front) or by using reflection to invoke the MoveNextAsync() and WaitToReadAsync() methods.

It was decided to not support this scenario until we get data that shows we need to implement this in SignalR.

Note that using ValueTypes in these scenarios isn't necessarily a performance gain because the T will be boxed (i.e. an allocation will occur) into the StreamItemMessage in:

https://github.com/dotnet/aspnetcore/blob/206b0aeca39d5eb12e55ce4e35ef4c8b9bc63c86/src/SignalR/clients/csharp/Client.Core/src/HubConnection.cs#L856-L858

cc @BrennanConroy

BrennanConroy commented 3 months ago

Backlog, waiting for customer feedback.

eerhardt commented 3 months ago

I found out that Blazor Server uses IAsyncEnumerable<ArraySegment<byte>> in its Hub:

https://github.com/dotnet/aspnetcore/blob/94259788d58e16ba753900b4bf855a6aee08dcb1/src/Components/Server/src/ComponentHub.cs#L269

Which means Blazor Server won't work if we keep this limitation.

Given this information, we will need to support this scenario (at least for the server, but we might as well do the client as well). Right now, the only way I can see this working is by using pure reflection to read from the streaming objects.

eerhardt commented 3 months ago

There is a scenario here we can't solve without a source generator. Say I have a Hub defined like the following:

public class MyHub : Hub
{
    public async Task EnumerableValueTypeParameter(IAsyncEnumerable<int> source)
    {
        await foreach (var item in source)
        {
            // do something with item
        }
    }
}

The problem is that in order to invoke the MyHub.EnumerableValueTypeParameter method on the SignalR server, we need to be able to create an actual IAsyncEnumerable<int> instance on the server. We need a real instance of this type because we are going to pass it into the user-defined Hub's method. However, it isn't possible to create an instance of this type in native AOT using reflection. For classes / reference types, we can call MakeGenericMethod/Type to dynamically create the concrete object. But for a ValueType, it isn't guaranteed the AOT'd code for the ValueType will exist (and very likely won't exist since we call MakeGenericMethod on our own methods, which never get instantiated for the ValueType). The same applies for a ChannelReader<ValueType> parameter on the server as well.

For the other 3 cases, we can provide support on native AOT using reflection. The other 3 cases are:

  1. The server Hub's method returns an IAsyncEnumerable/ChannelReader of ValueType.
  2. On the client, passing in a parameter of IAsyncEnumerable/ChannelReader of ValueType.
    1. The difference here (and for a server Hub's method return value) is that the user's code is creating the IAsyncEnumerable/ChannelReader of ValueType. The SignalR library code just needs to read from it, which can be done using reflection.
  3. On the client, consuming a return value of IAsyncEnumerable/ChannelReader of ValueType.
    1. The difference here is that in order to consume the IAsyncEnumerable/ChannelReader, the user's code calls a generic method, passing in the type - IAsyncEnumerable<TResult> StreamAsync<TResult> or Task<ChannelReader<TResult>> StreamAsChannelAsync<TResult>. In this case, since we know the generic type, we can create a generic Channel<TResult> or have a generic class that implements IAsyncEnumerable<TResult>. There's no need for reflection/MakeGenericMethod.

For .NET 9 we are able to support these 3 cases. For the case on the server where a parameter of a Hub method takes an IAsyncEnumerable/ChannelReader of ValueType, we will continue throwing an exception for PublishAot=true.