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 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 and 8.0, the APIs were published as preview features. Starting with .NET 9, these APIs are no longer considered preview features and are now deemed stable.
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 and is also available in some official repositories, such as the Alpine Packages - libmsquic.
Installing libmsquic
from Microsoft's official Linux package repository
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 package 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 install libmsquic
APK
sudo apk add libmsquic
DNF
sudo dnf install libmsquic
zypper
sudo zypper install libmsquic
YUM
sudo yum install libmsquic
Installing libmsquic
from the Distribution Package Repository
Installing libmsquic
from distribution package repository is also possible, but currently this is only available for Alpine
.
Examples
Here are some examples of using a package manager to install libmsquic
:
- Alpine 3.21 and later
apk add libmsquic
- Alpine 3.20 and older
# Get libmsquic from community repository edge branch.
apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ 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.
libnuma1
macOS
QUIC is now partially supported on macOS through a non-standard Homebrew package manager with some limitations. You can install libmsquic
on macOS using Homebrew with the following command:
brew install libmsquic
To run a .NET application that uses libmsquic
, you need to set the environment variable before running it. This ensures the application can find the libmsquic
library during runtime dynamic loading. You can do this by adding the following command before your main command:
DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib dotnet run
or
DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib ./binaryname
Alternatively, you can set the environment variable with:
export DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib
and then run your main command:
./binaryname
API overview
System.Net.Quic brings three major classes that enable the usage of QUIC protocol:
- QuicListener - server side class for accepting incoming connections.
- QuicConnection - QUIC connection, corresponding to RFC 9000 Section 5.
- QuicStream - QUIC stream, corresponding to RFC 9000 Section 2.
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
{
// Specify the application protocols that the server supports. This list must be a subset of the protocols specified in QuicListenerOptions.ApplicationProtocols.
ApplicationProtocols = [new 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
{
// Define the endpoint on which the server will listen for incoming connections. The port number 0 can be replaced with any valid port number as needed.
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
// List of all supported application protocols by this listener.
ApplicationProtocols = [new 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 SslApplicationProtocol("protocol-name")],
// The name of the server the client is trying to connect to. Used for server certificate validation.
TargetHost = ""
}
};
// 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.
// Opening a stream reserves it but does not notify the peer or send any data. If you don't send data, the peer
// won't be informed about the stream, which can cause AcceptInboundStreamAsync() to hang. To avoid this, ensure
// you send data on the stream to properly initiate communication.
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();
for 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).
Note
Opening a stream only reserves it without sending any data. This approach is designed to optimize network usage by avoiding the transmission of nearly empty frames. Since the peer is not notified until actual data is sent, the stream remains inactive from the peer's perspective. If you don't send data, the peer won't recognize the stream, which can cause AcceptInboundStreamAsync()
to hang as it waits for a meaningful stream. To ensure proper communication, you need to send data after opening the stream.
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.OpenOutboundStreamAsync(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, completeWrites: 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.AcceptInboundStreamAsync(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.