dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
14.96k stars 4.65k forks source link

[API Proposal]: Add property bag to QuicConnection #72511

Open JamesNK opened 2 years ago

JamesNK commented 2 years ago

Background and motivation

QuicListenerOptions.ConnectionOptionsCallback is a callback that allows the server to modify connection options when it accepts a connection. One of its arguments is QuicConnection.

https://github.com/dotnet/runtime/blob/a54a823ece1094dd05b7380614bd43566834a8f9/src/libraries/System.Net.Quic/src/System/Net/Quic/QuicListenerOptions.cs#L33-L36

In ASP.NET Core we allow people to hook into this callback, although we wrap it in our own signature because we have an abstraction called ConnectionContext for a server connection that's not specific to QUIC or HTTP/3.

_quicListenerOptions = new QuicListenerOptions
{
    ApplicationProtocols = _tlsConnectionOptions.ApplicationProtocols,
    ListenEndPoint = listenEndPoint,
    ListenBacklog = options.Backlog,
    ConnectionOptionsCallback = async (quicConnection, helloInfo, cancellationToken) =>
    {
        var serverAuthenticationOptions = await _tlsConnectionOptions.OnConnection(new TlsConnectionCallbackContext
        {
            CancellationToken = cancellationToken,
            ClientHelloInfo = helloInfo,
            State = _tlsConnectionOptions.OnConnectionState,
            Connection = new QuicConnectionContext(quicConnection, _context), // wrap QuicConnection with our abstraction here
        });
        var connectionOptions = new QuicServerConnectionOptions
        {
            ServerAuthenticationOptions = serverAuthenticationOptions,
            IdleTimeout = options.IdleTimeout,
            MaxInboundBidirectionalStreams = options.MaxBidirectionalStreamCount,
            MaxInboundUnidirectionalStreams = options.MaxUnidirectionalStreamCount,
            DefaultCloseErrorCode = 0,
            DefaultStreamErrorCode = 0,
        };
        return connectionOptions;
    }
}

Once the connection is accepted and returned from

var quicConnection = await _listener.AcceptConnectionAsync(cancellationToken);
var connectionContext = new QuicConnectionContext(quicConnection, _context);

The code above is creating our abstraction - QuicConnectionContext - twice: once in the callback and then again after AcceptConnectionAsync returns. We don't want to do this because of allocations, but also because someone can update state on our connection abstraction, and that would disappear if it was recreated.

It would be better if we were able to create QuicConnectionContext once inside the callback, use it, then add it to a property bag on QuicConnection, and then use that value when AcceptConnectionAsync returns.

var quicConnection = await _listener.AcceptConnectionAsync(cancellationToken);
var connectionContext = (QuicConnectionContext)quicConnection.Properties[QuicConnectionContextKey];

Other options include adding QuicConnectionContext to a ConditionalWeakTable<QuicConnection, QuicConnectionContext> inside the callback, then removing it outside the callback. But a place to stash state on the QuicConnection would be much simpler.

This isn't critical for .NET 7. Workarounds include using a weak table, allocating twice (which fixes one problem but creates another...), or leaving ConnectionContext null in the callback in .NET 7 and waiting for this API in .NET 8.

API Proposal

namespace System.Net.Quic;

public class QuicConnection
{
    public IDictionary<string, object?> Properties { get; }
}

API Usage

_quicListenerOptions = new QuicListenerOptions
{
    ApplicationProtocols = _tlsConnectionOptions.ApplicationProtocols,
    ListenEndPoint = listenEndPoint,
    ListenBacklog = options.Backlog,
    ConnectionOptionsCallback = async (quicConnection, helloInfo, cancellationToken) =>
    {
        quicConnection.Properties[QuicConnectionContextKey] = new QuicConnectionContext(quicConnection, _context);

        // ...figure out conneciton options...

        return connectionOptions;
    }
}
var quicConnection = await _listener.AcceptConnectionAsync(cancellationToken);
var connectionContext = (QuicConnectionContext)quicConnection.Properties[QuicConnectionContextKey];

Alternative Designs

Add a state argument to ConnectionOptionsCallback callback and AcceptConnectionAsync.

_quicListenerOptions = new QuicListenerOptions
{
    ApplicationProtocols = _tlsConnectionOptions.ApplicationProtocols,
    ListenEndPoint = listenEndPoint,
    ListenBacklog = options.Backlog,
    ConnectionOptionsCallback = async (quicConnection, helloInfo, state, cancellationToken) =>
    {
        var listenerContext = (QuicListenerContext)state!
        listenerContext._acceptingQuicConnectionContext = new QuicConnectionContext(quicConnection, _context);

        // ...figure out conneciton options...

        return connectionOptions;
    }
}
var quicConnection = await _listener.AcceptConnectionAsync(cancellationToken, state: this);
var connectionContext = _acceptingQuicConnectionContext; // was set in callback

Risks

No response

ghost commented 2 years ago

Tagging subscribers to this area: @dotnet/ncl See info in area-owners.md if you want to be subscribed.

Issue Details
### Background and motivation `QuicListenerOptions.ConnectionOptionsCallback` is a callback that allows the server to modify connection options when it accepts a connection. One of its arguments is `QuicConnection`. https://github.com/dotnet/runtime/blob/a54a823ece1094dd05b7380614bd43566834a8f9/src/libraries/System.Net.Quic/src/System/Net/Quic/QuicListenerOptions.cs#L33-L36 In ASP.NET Core we allow people to hook into this callback, although we wrap it in our own signature because we have an abstraction called [`ConnectionContext`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.connections.connectioncontext) for a server connection that's not specific to QUIC or HTTP/3. ```cs _quicListenerOptions = new QuicListenerOptions { ApplicationProtocols = _tlsConnectionOptions.ApplicationProtocols, ListenEndPoint = listenEndPoint, ListenBacklog = options.Backlog, ConnectionOptionsCallback = async (quicConnection, helloInfo, cancellationToken) => { var serverAuthenticationOptions = await _tlsConnectionOptions.OnConnection(new TlsConnectionCallbackContext { CancellationToken = cancellationToken, ClientHelloInfo = helloInfo, State = _tlsConnectionOptions.OnConnectionState, Connection = new QuicConnectionContext(quicConnection, _context), // wrap QuicConnection with our abstraction here }); var connectionOptions = new QuicServerConnectionOptions { ServerAuthenticationOptions = serverAuthenticationOptions, IdleTimeout = options.IdleTimeout, MaxInboundBidirectionalStreams = options.MaxBidirectionalStreamCount, MaxInboundUnidirectionalStreams = options.MaxUnidirectionalStreamCount, DefaultCloseErrorCode = 0, DefaultStreamErrorCode = 0, }; return connectionOptions; } } ``` Once the connection is accepted and returned from ```cs var quicConnection = await _listener.AcceptConnectionAsync(cancellationToken); var connectionContext = new QuicConnectionContext(quicConnection, _context); ``` The code above is creating our abstraction - `QuicConnectionContext` - twice: once in the callback and then again after `AcceptConnectionAsync` returns. We don't want to do this because of allocations, but also because someone can update state on our connection abstraction, and that would disappear if it was recreated. It would be better if we were able to create `QuicConnectionContext` once inside the callback, use it, then add it to a property bag on `QuicConnection`, and then use that value when `AcceptConnectionAsync` returns. ```cs var quicConnection = await _listener.AcceptConnectionAsync(cancellationToken); var connectionContext = (QuicConnectionContext)quicConnection.Properties[QuicConnectionContextKey]; ``` Other options include adding `QuicConnectionContext` to a `ConditionalWeakTable` inside the callback, then removing it outside the callback. But a place to stash state on the `QuicConnection` would be much simpler. This isn't critical for .NET 7. Workarounds include using a weak table, allocating twice (yuck), or leaving `ConnectionContext` null in the callback and waiting for this API in .NET 8. ### API Proposal ```csharp namespace System.Net.Quic; public class QuicConnection { public IDictionary Properties { get; } } ``` ### API Usage ```cs _quicListenerOptions = new QuicListenerOptions { ApplicationProtocols = _tlsConnectionOptions.ApplicationProtocols, ListenEndPoint = listenEndPoint, ListenBacklog = options.Backlog, ConnectionOptionsCallback = async (quicConnection, helloInfo, cancellationToken) => { quicConnection.Properties[QuicConnectionContextKey] = new QuicConnectionContext(quicConnection, _context); // ...figure out conneciton options... return connectionOptions; } } ``` ```cs var quicConnection = await _listener.AcceptConnectionAsync(cancellationToken); var connectionContext = (QuicConnectionContext)quicConnection.Properties[QuicConnectionContextKey]; ``` ### Alternative Designs Add a state argument to `ConnectionOptionsCallback` callback and `AcceptConnectionAsync`. ```cs _quicListenerOptions = new QuicListenerOptions { ApplicationProtocols = _tlsConnectionOptions.ApplicationProtocols, ListenEndPoint = listenEndPoint, ListenBacklog = options.Backlog, ConnectionOptionsCallback = async (quicConnection, helloInfo, state, cancellationToken) => { var listenerContext = (QuicListenerContext)state! listenerContext._acceptingQuicConnectionContext = new QuicConnectionContext(quicConnection, _context); // ...figure out conneciton options... return connectionOptions; } } ``` ```cs var quicConnection = await _listener.AcceptConnectionAsync(cancellationToken, state: this); var connectionContext = _acceptingQuicConnectionContext; // was set in callback ``` ### Risks _No response_
Author: JamesNK
Assignees: -
Labels: `api-suggestion`, `area-System.Net.Quic`
Milestone: -
JamesNK commented 2 years ago

Following on from the alternative design idea:

  1. Is ConnectionOptionsCallback only invoked when AcceptConnectionAsync is called? And it is always finished before AcceptConnectionAsync is done. If it isn't then this approach won't work.
  2. Passing in state isn't really necessary. We can set a _acceptingQuicConnectionContext field via closure.
JamesNK commented 2 years ago

I updated ASP.NET Core to create our abstraction inside the callback and get/set it on a field. It appears to work: https://github.com/dotnet/aspnetcore/pull/42774

It feels a little hacky. A property bag on QuicConnection would be easier to follow.

karelz commented 2 years ago

Triage: We do not like the Property bag -- feels kind of very general-purpose, which bit us in the past on other APIs. The alternative proposal sounds reasonable.

We expected that it is sufficient for 8.0 as we are past Platform Complete. Is it correct @JamesNK?

JamesNK commented 2 years ago

The alternative proposal doesn't work. The callback isn't run when AcceptConnectionAsync is called. It happens in a background thread. Saving state of the current connection via closure doesn't work. Neither will passing in state via AcceptConnectionAsync

That brings us back to the idea of adding a property bag to QuicConnection.

I will see whether using a ConditionalWeakTable works but it feels hacky compared to a collection on QuicConnection itself.

ManickaP commented 2 years ago

The alternative proposal doesn't work. The callback isn't run when AcceptConnectionAsync is called. It happens in a background thread.

And we can change that if we decide to support the state object. It's similar to my alternative design for QuicListener in https://github.com/dotnet/runtime/issues/67560.

ManickaP commented 1 year ago

Triage: the decision must be done 8.0 (no matter which one) as this might alter an existing API. For that reason, this is a high prio.

ManickaP commented 3 months ago

Would simple object? StateObject property instead of dictionary work for you @JamesNK?

JamesNK commented 3 months ago

I think so. But a dictionary seems more flexible (multiple APIs could use it to stash information without overwriting each others changes) and it is like SocketsHttpHandler.Properties and HttpRequestMessage.Properties.

Are you worried about performance? The dictionary could be lazily allocated if it is used to make it pay for play.

ManickaP commented 3 months ago

So we had another discussion about this internally. We're not fans of the dictionary, despite being a similar approach as in HTTP stack (we obsoleted that API some time ago and replaced it with the Options class which makes us wary of this approach). We'd still prefer to use a single object instead. Since ASP is the only consumer for this and you have a workaround, we decided to postpone the API for the time being and not introduce anything yet. We can make the final decision when we have more people wanting this (or something similar) and we have more feedback/input on this.