QUIC protocol

QUIC is a network transport layer protocol standardized in RFC 9000. It uses UDP as an underlying protocol and it's inherently secure as it mandates TLS 1.3 usage. For more information, see RFC 9001. Another interesting difference from well-known transport protocols such as TCP and UDP is that it has stream multiplexing built-in on the transport layer. This allows having multiple, concurrent, independent data streams that don't affect each other.

QUIC itself doesn't define any semantics for the exchanged data as it's a transport protocol. It's rather used in application layer protocols, for example in HTTP/3 or in SMB over QUIC. It can also be used for any custom-defined protocol.

The protocol offers many advantages over TCP with TLS, here are a few:

  • Faster connection establishment as it doesn't require as many round trips as TCP with TLS on top.
  • Avoidance of head-of-line blocking problem where one lost packet doesn't block data of all the other streams.

On the other hand, there are potential disadvantages to consider when using QUIC. As a newer protocol, its adoption is still growing and limited. Apart from that, QUIC traffic may be even blocked by some networking components.

QUIC in .NET

The QUIC implementation was introduced in .NET 5 as the System.Net.Quic library. However, up until .NET 7.0 the library was strictly internal and served only as an implementation of HTTP/3. With .NET 7, the library was made public thus exposing its APIs.

Note

In .NET 7.0, the APIs are published as preview features.

From the implementation perspective, System.Net.Quic depends on MsQuic, the native implementation of QUIC protocol. As a result, System.Net.Quic platform support and dependencies are inherited from MsQuic and documented in the Platform dependencies section. In short, the MsQuic library is shipped as part of .NET for Windows. But for Linux, you must manually install libmsquic via an appropriate package manager. For the other platforms, it's still possible to build MsQuic manually, whether against SChannel or OpenSSL, and use it with System.Net.Quic. However, these scenarios are not part of our testing matrix and unforeseen problems might occur.

Platform dependencies

The following sections describe the platform dependencies for QUIC in .NET.

Windows

  • Windows 11, Windows Server 2022, or later. (Earlier Windows versions are missing the cryptographic APIs required to support QUIC.)

On Windows, msquic.dll is distributed as part of the .NET runtime, and no other steps are required to install it.

Linux

Note

.NET 7+ is only compatible with 2.2+ versions of libmsquic.

The libmsquic package is required on Linux. This package is published in Microsoft's official Linux package repository, https://packages.microsoft.com. You must add this repository to your package manager before installing the package. For more information, see Linux Software Repository for Microsoft Products.

Caution

Adding the Microsoft pacakge repository may conflict with your distribution's repository when your distribution's repository provides .NET and other Microsoft packages. To avoid or troubleshoot package mixups, review Troubleshoot .NET errors related to missing files on Linux.

Examples

Here are some examples of using a package manager to install libmsquic:

  • APT

    sudo apt-get libmsquic 
    
  • APK

    sudo apk add libmsquic
    
  • DNF

    sudo dnf install libmsquic
    
  • zypper

    sudo zypper install libmsquic
    
  • YUM

    sudo yum install libmsquic
    
Dependencies of libmsquic

All the following dependencies are stated in the libmsquic package manifest and are automatically installed by the package manager:

  • OpenSSL 3+ or 1.1 - depends on the default OpenSSL version for the distribution version, for example, OpenSSL 3 for Ubuntu 22 and OpenSSL 1.1 for Ubuntu 20.

  • libnuma 1

macOS

QUIC isn't currently supported on macOS but may be available in a future release.

API overview

System.Net.Quic brings three major classes that enable the usage of QUIC protocol:

But before using these classes, your code should check whether QUIC is currently supported, as libmsquic might be missing, or TLS 1.3 might not be supported. For that, both QuicListener and QuicConnection expose a static property IsSupported:

if (QuicListener.IsSupported)
{
    // Use QuicListener
}
else
{
    // Fallback/Error
}

if (QuicConnection.IsSupported)
{
    // Use QuicConnection
}
else
{
    // Fallback/Error
}

These properties will report the same value, but that might change in the future. It's recommended to check IsSupported for server-scenarios and IsSupported for the client ones.

QuicListener

QuicListener represents a server side class that accepts incoming connections from the clients. The listener is constructed and started with a static method ListenAsync(QuicListenerOptions, CancellationToken). The method accepts an instance of QuicListenerOptions class with all the settings necessary to start the listener and accept incoming connections. After that, listener is ready to hand out connections via AcceptConnectionAsync(CancellationToken). Connections returned by this method are always fully connected, meaning that the TLS handshake is finished and the connection is ready to be used. Finally, to stop listening and release all resources, DisposeAsync() must be called.

Consider the following QuicListener example code:

using System.Net.Quic;

// First, check if QUIC is supported.
if (!QuicListener.IsSupported)
{
    Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
    return;
}

// Share configuration for each incoming connection.
// This represents the minimal configuration necessary.
var serverConnectionOptions = new QuicServerConnectionOptions
{
    // Used to abort stream if it's not properly closed by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.

    // Used to close the connection if it's not done by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.

    // Same options as for server side SslStream.
    ServerAuthenticationOptions = new SslServerAuthenticationOptions
    {
        // List of supported application protocols, must be the same or subset of QuicListenerOptions.ApplicationProtocols.
        ApplicationProtocols = new List<SslApplicationProtocol>() { "protocol-name" },
        // Server certificate, it can also be provided via ServerCertificateContext or ServerCertificateSelectionCallback.
        ServerCertificate = serverCertificate
    }
};

// Initialize, configure the listener and start listening.
var listener = await QuicListener.ListenAsync(new QuicListenerOptions
{
    // Listening endpoint, port 0 means any port.
    ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
    // List of all supported application protocols by this listener.
    ApplicationProtocols = new List<SslApplicationProtocol>() { "protocol-name" },
    // Callback to provide options for the incoming connections, it gets called once per each connection.
    ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(serverConnectionOptions)
});

// Accept and process the connections.
while (isRunning)
{
    // Accept will propagate any exceptions that occurred during the connection establishment,
    // including exceptions thrown from ConnectionOptionsCallback, caused by invalid QuicServerConnectionOptions or TLS handshake failures.
    var connection = await listener.AcceptConnectionAsync();

    // Process the connection...
}

// When finished, dispose the listener.
await listener.DisposeAsync();

For more information about how the QuicListener was designed, see the API proposal.

QuicConnection

QuicConnection is a class used for both server and client side QUIC connections. Server side connections are created internally by the listener and handed out via AcceptConnectionAsync(CancellationToken). Client side connections must be opened and connected to the server. As with the listener, there's a static method ConnectAsync(QuicClientConnectionOptions, CancellationToken) that instantiates and connects the connection. It accepts an instance of QuicClientConnectionOptions, an analogous class to QuicServerConnectionOptions. After that, the work with the connection doesn't differ between client and server. It can open outgoing streams and accept incoming ones. It also provides properties with information about the connection, like LocalEndPoint, RemoteEndPoint, or RemoteCertificate.

When the work with the connection is done, it needs to be closed and disposed. QUIC protocol mandates using an application layer code for immediate closure, see RFC 9000 Section 10.2. For that, CloseAsync(Int64, CancellationToken) with application layer code can be called or if not, DisposeAsync() will use the code provided in DefaultCloseErrorCode. Either way, DisposeAsync() must be called at the end of the work with the connection to fully release all the associated resources.

Consider the following QuicConnection example code:

using System.Net.Quic;

// First, check if QUIC is supported.
if (!QuicConnection.IsSupported)
{
    Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
    return;
}

// This represents the minimal configuration necessary to open a connection.
var clientConnectionOptions = new QuicClientConnectionOptions
{
    // End point of the server to connect to.
    RemoteEndPoint = listener.LocalEndPoint,

    // Used to abort stream if it's not properly closed by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.

    // Used to close the connection if it's not done by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.

    // Optionally set limits for inbound streams.
    MaxInboundUnidirectionalStreams = 10,
    MaxInboundBidirectionalStreams = 100,

    // Same options as for client side SslStream.
    ClientAuthenticationOptions = new SslClientAuthenticationOptions
    {
        // List of supported application protocols.
        ApplicationProtocols = new List<SslApplicationProtocol>() { "protocol-name" }
    }
};

// Initialize, configure and connect to the server.
var connection = await QuicConnection.ConnectAsync(clientConnectionOptions);

Console.WriteLine($"Connected {connection.LocalEndPoint} --> {connection.RemoteEndPoint}");

// Open a bidirectional (can both read and write) outbound stream.
var outgoingStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);

// Work with the outgoing stream ...

// To accept any stream on a client connection, at least one of MaxInboundBidirectionalStreams or MaxInboundUnidirectionalStreams of QuicConnectionOptions must be set.
while (isRunning)
{
    // Accept an inbound stream.
    var incomingStream = await connection.AcceptInboundStreamAsync();

    // Work with the incoming stream ...
}

// Close the connection with the custom code.
await connection.CloseAsync(0x0C);

// Dispose the connection.
await connection.DisposeAsync();

or more information about how the QuicConnection was designed, see the API proposal.

QuicStream

QuicStream is the actual type that is used to send and receive data in QUIC protocol. It derives from ordinary Stream and can be used as such, but it also offers several features that are specific to QUIC protocol. Firstly, a QUIC stream can either be unidirectional or bidirectional, see RFC 9000 Section 2.1. A bidirectional stream is able to send and receive data on both sides, whereas unidirectional stream can only write from the initiating side and read on the accepting one. Each peer can limit how many concurrent stream of each type is willing to accept, see MaxInboundBidirectionalStreams and MaxInboundUnidirectionalStreams.

Another particularity of QUIC stream is ability to explicitly close the writing side in the middle of work with the stream, see CompleteWrites() or WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken) overload with completeWrites argument. Closing of the writing side lets the peer know that no more data will arrive, yet the peer still can continue sending (in case of a bidirectional stream). This is useful in scenarios like HTTP request/response exchange when the client sends the request and closes the writing side to let the server know that this is the end of the request content. Server is still able to send the response after that, but knows that no more data will arrive from the client. And for erroneous cases, either writing or reading side of the stream can be aborted, see Abort(QuicAbortDirection, Int64). The behavior of the individual methods for each stream type is summarized in the following table (note that both client and server can open and accept streams):

Method Peer opening stream Peer accepting stream
CanRead bidirectional: true
unidirectional: false
true
CanWrite true bidirectional: true
unidirectional: false
ReadAsync bidirectional: reads data
unidirectional: InvalidOperationException
reads data
WriteAsync sends data => peer read returns the data bidirectional: sends data => peer read returns the data
unidirectional: InvalidOperationException
CompleteWrites closes writing side => peer read returns 0 bidirectional: closes writing side => peer read returns 0
unidirectional: no-op
Abort(QuicAbortDirection.Read) bidirectional: STOP_SENDING => peer write throws QuicException(QuicError.OperationAborted)
unidirectional: no-op
STOP_SENDING => peer write throws QuicException(QuicError.OperationAborted)
Abort(QuicAbortDirection.Write) RESET_STREAM => peer read throws QuicException(QuicError.OperationAborted) bidirectional: RESET_STREAM => peer read throws QuicException(QuicError.OperationAborted)
unidirectional: no-op

On top of these methods, QuicStream offers two specialized properties to get notified whenever either reading or writing side of the stream has been closed: ReadsClosed and WritesClosed. Both return a Task that completes with its corresponding side getting closed, whether it be success or abort, in which case the Task will contain appropriate exception. These properties are useful when the user code needs to know about stream side getting closed without issuing call to ReadAsync or WriteAsync.

Finally, when the work with the stream is done, it needs to be disposed with DisposeAsync(). The dispose will make sure that both reading and/or writing side - depending on the stream type - is closed. If stream hasn't been properly read till the end, dispose will issue an equivalent of Abort(QuicAbortDirection.Read). However, if stream writing side hasn't been closed, it will be gracefully closed as it would be with CompleteWrites. The reason for this difference is to make sure that scenarios working with an ordinary Stream behave as expected and lead to a successful path. Consider the following example:

// Work done with all different types of streams.
async Task WorkWithStreamAsync(Stream stream)
{
    // This will dispose the stream at the end of the scope.
    await using (stream)
    {
        // Simple echo, read data and send them back.
        byte[] buffer = new byte[1024];
        int count = 0;
        // The loop stops when read returns 0 bytes as is common for all streams.
        while ((count = await stream.ReadAsync(buffer)) > 0)
        {
            await stream.WriteAsync(buffer.AsMemory(0, count));
        }
    }
}

// Open a QuicStream and pass to the common method.
var quicStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);
await WorkWithStreamAsync(quicStream);

The sample usage of QuicStream in client scenario:

// Consider connection from the connection example, open a bidirectional stream.
await using var stream = await connection.OpenStreamAsync(QuicStreamType.Bidirectional, cancellationToken);

// Send some data.
await stream.WriteAsync(data, cancellationToken);
await stream.WriteAsync(data, cancellationToken);

// End the writing-side together with the last data.
await stream.WriteAsync(data, endStream: true, cancellationToken);
// Or separately.
stream.CompleteWrites();

// Read data until the end of stream.
while (await stream.ReadAsync(buffer, cancellationToken) > 0)
{
    // Handle buffer data...
}

// DisposeAsync called by await using at the top.

And the sample usage of QuicStream in server scenario:

// Consider connection from the connection example, accept a stream.
await using var stream = await connection.AcceptStreamAsync(cancellationToken);

if (stream.Type != QuicStreamType.Bidirectional)
{
    Console.WriteLine($"Expected bidirectional stream, got {stream.Type}");
    return;
}

// Read the data.
while (stream.ReadAsync(buffer, cancellationToken) > 0)
{
    // Handle buffer data...

    // Client completed the writes, the loop might be exited now without another ReadAsync.
    if (stream.ReadsCompleted.IsCompleted)
    {
        break;
    }
}

// Listen for Abort(QuicAbortDirection.Read) from the client.
var writesClosedTask = WritesClosedAsync(stream);
async ValueTask WritesClosedAsync(QuicStream stream)
{
    try
    {
        await stream.WritesClosed;
    }
    catch (Exception ex)
    {
        // Handle peer aborting our writing side ...
    }
}

// DisposeAsync called by await using at the top.

For more information about how the QuicStream was designed, see the API proposal.

See also