File: System\Net\Quic\QuicListener.cs
Web Access
Project: src\src\libraries\System.Net.Quic\src\System.Net.Quic.csproj (System.Net.Quic)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Net.Security;
using System.Runtime.CompilerServices;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.Authentication;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Quic;
using static System.Net.Quic.MsQuicHelpers;
using static Microsoft.Quic.MsQuic;
 
using NEW_CONNECTION_DATA = Microsoft.Quic.QUIC_LISTENER_EVENT._Anonymous_e__Union._NEW_CONNECTION_e__Struct;
using STOP_COMPLETE_DATA = Microsoft.Quic.QUIC_LISTENER_EVENT._Anonymous_e__Union._STOP_COMPLETE_e__Struct;
 
namespace System.Net.Quic;
 
/// <summary>
/// Represents a listener that listens for incoming QUIC connections, see <see href="https://www.rfc-editor.org/rfc/rfc9000.html#name-connections">RFC 9000: Connections</see> for more details.
/// <see cref="QuicListener" /> allows accepting multiple <see cref="QuicConnection" />.
/// </summary>
/// <remarks>
/// Unlike the connection and stream, <see cref="QuicListener" /> lifetime is not linked to any of the accepted connections.
/// It can be safely disposed while keeping the accepted connection alive. The <see cref="DisposeAsync"/> will only stop listening for any other inbound connections.
/// </remarks>
public sealed partial class QuicListener : IAsyncDisposable
{
    /// <summary>
    /// Returns <c>true</c> if QUIC is supported on the current machine and can be used; otherwise, <c>false</c>.
    /// </summary>
    /// <remarks>
    /// The current implementation depends on <see href="https://github.com/microsoft/msquic">MsQuic</see> native library, this property checks its presence (Linux machines).
    /// It also checks whether TLS 1.3, requirement for QUIC protocol, is available and enabled (Windows machines).
    /// </remarks>
    [SupportedOSPlatformGuard("windows")]
    [SupportedOSPlatformGuard("linux")]
    [SupportedOSPlatformGuard("osx")]
    public static bool IsSupported => MsQuicApi.IsQuicSupported;
 
    /// <summary>
    /// Creates a new <see cref="QuicListener"/> and starts listening for new connections.
    /// </summary>
    /// <param name="options">Options for the listener.</param>
    /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
    /// <returns>An asynchronous task that completes with the started listener.</returns>
    public static ValueTask<QuicListener> ListenAsync(QuicListenerOptions options, CancellationToken cancellationToken = default)
    {
        if (!IsSupported)
        {
            throw new PlatformNotSupportedException(SR.Format(SR.SystemNetQuic_PlatformNotSupported, MsQuicApi.NotSupportedReason ?? "General loading failure."));
        }
 
        // Validate and fill in defaults for the options.
        options.Validate(nameof(options));
 
        QuicListener listener = new QuicListener(options);
 
        if (NetEventSource.Log.IsEnabled())
        {
            NetEventSource.Info(listener, $"{listener} Listener listens on {listener.LocalEndPoint}");
        }
 
        return ValueTask.FromResult(listener);
    }
 
    /// <summary>
    /// Handle to MsQuic listener object.
    /// </summary>
    private readonly MsQuicContextSafeHandle _handle;
 
    /// <summary>
    /// Set to true once disposed. Prevents double and/or concurrent disposal.
    /// </summary>
    private bool _disposed;
 
    /// <summary>
    /// Completed when SHUTDOWN_COMPLETE arrives.
    /// </summary>
    private readonly ValueTaskSource _shutdownTcs = new ValueTaskSource();
 
    /// <summary>
    /// Used to stop pending connections when <see cref="DisposeAsync"/> is requested.
    /// </summary>
    private readonly CancellationTokenSource _disposeCts = new CancellationTokenSource();
 
    /// <summary>
    /// Selects connection options for incoming connections.
    /// </summary>
    private readonly Func<QuicConnection, SslClientHelloInfo, CancellationToken, ValueTask<QuicServerConnectionOptions>> _connectionOptionsCallback;
 
    /// <summary>
    /// Incoming connections waiting to be accepted via AcceptAsync. The item will either be fully connected <see cref="QuicConnection"/> or <see cref="Exception"/> if the handshake failed.
    /// </summary>
    private readonly Channel<object> _acceptQueue;
    /// <summary>
    /// Allowed number of pending incoming connections.
    /// Actual value correspond to <c><see cref="QuicListenerOptions.ListenBacklog"/> - # <see cref="StartConnectionHandshake"/> in progress - <see cref="_acceptQueue"/>.Count</c> and is always <c>>= 0</c>.
    /// Starts as <see cref="QuicListenerOptions.ListenBacklog"/>, decrements with each NEW_CONNECTION, increments with <see cref="AcceptConnectionAsync" />.
    /// </summary>
    private int _pendingConnectionsCapacity;
 
    /// <summary>
    /// The actual listening endpoint.
    /// </summary>
    public IPEndPoint LocalEndPoint { get; }
 
    /// <inheritdoc />
    public override string ToString() => _handle.ToString();
 
    /// <summary>
    /// Initializes and starts a new instance of a <see cref="QuicListener" />.
    /// </summary>
    /// <param name="options">Options to start the listener.</param>
    private unsafe QuicListener(QuicListenerOptions options)
    {
        GCHandle context = GCHandle.Alloc(this, GCHandleType.Weak);
        try
        {
            QUIC_HANDLE* handle;
            ThrowHelper.ThrowIfMsQuicError(MsQuicApi.Api.ListenerOpen(
                MsQuicApi.Api.Registration,
                &NativeCallback,
                (void*)GCHandle.ToIntPtr(context),
                &handle),
                "ListenerOpen failed");
            _handle = new MsQuicContextSafeHandle(handle, context, SafeHandleType.Listener);
        }
        catch
        {
            context.Free();
            throw;
        }
 
        // Save the connection options before starting the listener
        _connectionOptionsCallback = options.ConnectionOptionsCallback;
        _acceptQueue = Channel.CreateUnbounded<object>();
        _pendingConnectionsCapacity = options.ListenBacklog;
 
        // Start the listener, from now on MsQuic events will come.
        using MsQuicBuffers alpnBuffers = new MsQuicBuffers();
        alpnBuffers.Initialize(options.ApplicationProtocols, applicationProtocol => applicationProtocol.Protocol);
        QuicAddr address = options.ListenEndPoint.ToQuicAddr();
        if (options.ListenEndPoint.Address.Equals(IPAddress.IPv6Any))
        {
            // For IPv6Any, MsQuic would listen only for IPv6 connections. This would make it impossible
            // to connect the listener by using the IPv4 address (which could have been e.g. resolved by DNS).
            // Using the Unspecified family makes MsQuic handle connections from all IP addresses.
            address.Family = QUIC_ADDRESS_FAMILY_UNSPEC;
        }
        ThrowHelper.ThrowIfMsQuicError(MsQuicApi.Api.ListenerStart(
            _handle,
            alpnBuffers.Buffers,
            (uint)alpnBuffers.Count,
            &address),
            "ListenerStart failed");
 
        // Get the actual listening endpoint.
        address = GetMsQuicParameter<QuicAddr>(_handle, QUIC_PARAM_LISTENER_LOCAL_ADDRESS);
        LocalEndPoint = MsQuicHelpers.QuicAddrToIPEndPoint(&address, options.ListenEndPoint.AddressFamily);
    }
 
    /// <summary>
    /// Accepts an inbound <see cref="QuicConnection" />.
    /// </summary>
    /// <remarks>
    /// Propagates exceptions from <see cref="QuicListenerOptions.ConnectionOptionsCallback"/>, including validation errors from misconfigured <see cref="QuicServerConnectionOptions"/>, e.g. <see cref="ArgumentException"/>.
    /// Also propagates exceptions from failed connection handshake, e.g. <see cref="AuthenticationException"/>, <see cref="QuicException"/>.
    /// </remarks>
    /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
    /// <returns>A task that will contain a fully connected <see cref="QuicConnection" /> which successfully finished the handshake and is ready to be used.</returns>
    public async ValueTask<QuicConnection> AcceptConnectionAsync(CancellationToken cancellationToken = default)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
 
        GCHandle keepObject = GCHandle.Alloc(this);
        try
        {
            object item = await _acceptQueue.Reader.ReadAsync(cancellationToken).ConfigureAwait(false);
            Interlocked.Increment(ref _pendingConnectionsCapacity);
 
            if (item is QuicConnection connection)
            {
                return connection;
            }
            ExceptionDispatchInfo.Throw((Exception)item);
            throw null; // Never reached.
        }
        catch (ChannelClosedException ex) when (ex.InnerException is not null)
        {
            ExceptionDispatchInfo.Throw(ex.InnerException);
            throw;
        }
        finally
        {
            keepObject.Free();
        }
    }
 
    /// <summary>
    /// Kicks off the handshake process. It doesn't propagate the result outside directly but rather stores it in <c>_acceptQueue</c> for <see cref="AcceptConnectionAsync" />.
    /// </summary>
    /// <remarks>
    /// The method is <c>async void</c> on purpose so it starts an operation but doesn't wait for the result from the caller's perspective.
    /// It does await <see cref="QuicConnection.FinishHandshakeAsync"/> but that never gets propagated to the caller for which the method ends with the first asynchronously processed <c>await</c>.
    /// Once the asynchronous processing finishes, the result is stored in <c>_acceptQueue</c>.
    /// </remarks>
    /// <param name="connection">The new connection.</param>
    /// <param name="clientHello">The TLS ClientHello data.</param>
    private async void StartConnectionHandshake(QuicConnection connection, SslClientHelloInfo clientHello)
    {
        // Yield to the threadpool immediately. This makes sure the connection options callback
        // provided by the user is not invoked from the MsQuic thread and cannot delay acks
        // or other operations on other connections.
        await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
 
        bool wrapException = false;
        CancellationToken cancellationToken = default;
 
        // In certain cases MsQuic will not impose the handshake idle timeout on their side, see
        // https://github.com/microsoft/msquic/discussions/2705.
        // This will be assigned to before the linked CTS is cancelled
        TimeSpan handshakeTimeout = QuicDefaults.HandshakeTimeout;
        try
        {
            using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_disposeCts.Token, connection.ConnectionShutdownToken);
            cancellationToken = linkedCts.Token;
            // Initial timeout for retrieving connection options.
            linkedCts.CancelAfter(handshakeTimeout);
 
            wrapException = true;
            QuicServerConnectionOptions options = await _connectionOptionsCallback(connection, clientHello, cancellationToken).ConfigureAwait(false);
            wrapException = false;
 
            options.Validate(nameof(options));
 
            // Update handshake timeout based on the returned value.
            handshakeTimeout = options.HandshakeTimeout;
            linkedCts.CancelAfter(handshakeTimeout);
 
            await connection.FinishHandshakeAsync(options, clientHello.ServerName, cancellationToken).ConfigureAwait(false);
            if (!_acceptQueue.Writer.TryWrite(connection))
            {
                // Channel has been closed, dispose the connection as it'll never be handed out.
                await connection.DisposeAsync().ConfigureAwait(false);
            }
        }
        catch (OperationCanceledException) when (_disposeCts.IsCancellationRequested)
        {
            // Handshake stopped by QuicListener.DisposeAsync:
            // 1. Dispose the connection and by that shut it down --> application error code doesn't matter here as this is a transport error.
            // 2. Connection won't be handed out since listener has stopped --> do not propagate anything.
 
            if (NetEventSource.Log.IsEnabled())
            {
                NetEventSource.Info(connection, $"{connection} Connection handshake stopped by listener");
            }
 
            await connection.DisposeAsync().ConfigureAwait(false);
        }
        catch (OperationCanceledException oce) when (cancellationToken.IsCancellationRequested && !connection.ConnectionShutdownToken.IsCancellationRequested)
        {
            // Handshake cancelled by options.HandshakeTimeout, probably stalled:
            // 1. Connection must be killed so dispose it and by that shut it down --> application error code doesn't matter here as this is a transport error.
            // 2. Connection won't be handed out since it's useless --> propagate appropriate exception, listener will pass it to the caller.
 
            if (NetEventSource.Log.IsEnabled())
            {
                NetEventSource.Error(connection, $"{connection} Connection handshake timed out: {oce}");
            }
 
            Exception ex = ExceptionDispatchInfo.SetCurrentStackTrace(new QuicException(QuicError.ConnectionTimeout, null, SR.Format(SR.net_quic_handshake_timeout, handshakeTimeout), oce));
            await connection.DisposeAsync().ConfigureAwait(false);
            if (!_acceptQueue.Writer.TryWrite(ex))
            {
                // Channel has been closed, connection is already disposed, do nothing.
            }
        }
        catch (Exception ex)
        {
            // Handshake failed:
            // 1. Dispose the connection and by that shut it down --> application error code doesn't matter here as this is a transport error.
            // 2. Connection cannot be handed out since it's useless --> propagate the exception as-is, listener will pass it to the caller.
 
            if (wrapException && connection.ConnectionShutdownToken.IsCancellationRequested)
            {
                // The connection was closed while we were waiting for the connection options callback to complete
                // ex is going to be an OperationCanceledException, but we want to propagate the original exception.
                // Since the inner _connectedTcs is already transitioned to faulted state (because ConnectionShutdownToken
                // fired), the parameters to FinishHandshakeAsync are not going to be validated and it will return the
                // faulted task.
                ValueTask task = connection.FinishHandshakeAsync(null!, null!, default);
                Debug.Assert(task.IsCompleted);
 
                // Unwrap AggregateException and propagate it to the accept queue.
                if (task.AsTask().Exception?.InnerException is Exception handshakeException)
                {
                    ex = handshakeException;
                    wrapException = false;
                }
            }
 
            if (NetEventSource.Log.IsEnabled())
            {
                NetEventSource.Error(connection, $"{connection} Connection handshake failed: {ex}");
            }
 
            await connection.DisposeAsync().ConfigureAwait(false);
            if (!_acceptQueue.Writer.TryWrite(
                    wrapException ?
                        ExceptionDispatchInfo.SetCurrentStackTrace(new QuicException(QuicError.CallbackError, null, SR.net_quic_callback_error, ex)) :
                        ex))
            {
                // Channel has been closed, connection is already disposed, do nothing.
            }
        }
    }
 
    private unsafe int HandleEventNewConnection(ref NEW_CONNECTION_DATA data)
    {
        // Check if there's capacity to have another connection waiting to be accepted.
        if (Interlocked.Decrement(ref _pendingConnectionsCapacity) < 0)
        {
            if (NetEventSource.Log.IsEnabled())
            {
                NetEventSource.Info(this, $"{this} Refusing connection from {MsQuicHelpers.QuicAddrToIPEndPoint(data.Info->RemoteAddress)} due to backlog limit");
            }
 
            Interlocked.Increment(ref _pendingConnectionsCapacity);
            return QUIC_STATUS_CONNECTION_REFUSED;
        }
 
        QuicConnection connection = new QuicConnection(data.Connection, data.Info);
 
        if (NetEventSource.Log.IsEnabled())
        {
            NetEventSource.Info(this, $"{this} New inbound connection {connection}.");
        }
 
        SslClientHelloInfo clientHello = new SslClientHelloInfo(data.Info->ServerNameLength > 0 ? Marshal.PtrToStringUTF8((IntPtr)data.Info->ServerName, data.Info->ServerNameLength) : "", SslProtocols.Tls13);
 
        // Kicks off the rest of the handshake in the background, the process itself will enqueue the result in the accept queue.
        StartConnectionHandshake(connection, clientHello);
 
        return QUIC_STATUS_SUCCESS;
 
    }
    private unsafe int HandleEventStopComplete()
    {
        _shutdownTcs.TrySetResult();
        return QUIC_STATUS_SUCCESS;
    }
 
    private unsafe int HandleListenerEvent(ref QUIC_LISTENER_EVENT listenerEvent)
        => listenerEvent.Type switch
        {
            QUIC_LISTENER_EVENT_TYPE.NEW_CONNECTION => HandleEventNewConnection(ref listenerEvent.NEW_CONNECTION),
            QUIC_LISTENER_EVENT_TYPE.STOP_COMPLETE => HandleEventStopComplete(),
            _ => QUIC_STATUS_SUCCESS
        };
 
#pragma warning disable CS3016
    [UnmanagedCallersOnly(CallConvs = new Type[] { typeof(CallConvCdecl) })]
#pragma warning restore CS3016
    private static unsafe int NativeCallback(QUIC_HANDLE* listener, void* context, QUIC_LISTENER_EVENT* listenerEvent)
    {
        GCHandle stateHandle = GCHandle.FromIntPtr((IntPtr)context);
 
        // Check if the instance hasn't been collected.
        if (!stateHandle.IsAllocated || stateHandle.Target is not QuicListener instance)
        {
            if (NetEventSource.Log.IsEnabled())
            {
                NetEventSource.Error(null, $"Received event {listenerEvent->Type} for [list][{(nint)listener:X11}] while listener is already disposed");
            }
            return QUIC_STATUS_INVALID_STATE;
        }
 
        try
        {
            // Process the event.
            if (NetEventSource.Log.IsEnabled())
            {
                NetEventSource.Info(instance, $"{instance} Received event {listenerEvent->Type} {listenerEvent->ToString()}");
            }
            return instance.HandleListenerEvent(ref *listenerEvent);
        }
        catch (Exception ex)
        {
            if (NetEventSource.Log.IsEnabled())
            {
                NetEventSource.Error(instance, $"{instance} Exception while processing event {listenerEvent->Type}: {ex}");
            }
            return QUIC_STATUS_INTERNAL_ERROR;
        }
    }
 
    /// <summary>
    /// Stops listening for new connections and releases all resources associated with the listener.
    /// </summary>
    /// <returns>A task that represents the asynchronous dispose operation.</returns>
    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref _disposed, true))
        {
            return;
        }
 
        if (NetEventSource.Log.IsEnabled())
        {
            NetEventSource.Info(this, $"{this} Disposing.");
        }
 
        // Check if the listener has been shut down and if not, shut it down.
        if (_shutdownTcs.TryInitialize(out ValueTask valueTask, this))
        {
            unsafe
            {
                MsQuicApi.Api.ListenerStop(_handle);
            }
        }
 
        // Wait for STOP_COMPLETE, the last event, so that all resources can be safely released.
        await valueTask.ConfigureAwait(false);
        _handle.Dispose();
 
        // Flush the queue and dispose all remaining connections.
        await _disposeCts.CancelAsync().ConfigureAwait(false);
        _acceptQueue.Writer.TryComplete(ExceptionDispatchInfo.SetCurrentStackTrace(new ObjectDisposedException(GetType().FullName)));
        while (_acceptQueue.Reader.TryRead(out object? item))
        {
            if (item is QuicConnection connection)
            {
                await connection.DisposeAsync().ConfigureAwait(false);
            }
        }
    }
}