Skip to content

[API Proposal]: [QUIC] QuicStream #69675

@ManickaP

Description

@ManickaP

Background and motivation

API design for exposing QuicStream and related classes to the public.

The API shape is mainly defined by Stream from which it derives. QuicStream has some additional APIs specific for QUIC, but it still has a goal of QuicStream to be usable as an ordinary stream, e.g.: allow usage like this:

await using var stream = ...; // Get QuicStream
await stream.WriteAsync(...);
await stream.ReadAsync(...);
// Dispose by await using

Albeit better results can be achieved via the additional API. For example from the previous code snippet, the stream didn't complete writes until DisposeAsync got called. Thus the peer might still be expecting incoming data and never send any itself. All of this depends on the specific protocol and the contract between the peers. For instance in HTTP/3 case, we wrap QuicStream into HTTP/3 specific stream taking care of all the proprieties with completing the write side of the stream, which is then handed out in the response.

This API proposal concentrates on the additional, QUIC specific APIs.

Related issues:

API Proposal

namespace System.Net.Quic;

public sealed class QuicStream : Stream
{
    // Stream API implied, QUIC specifics follow:

    public long Id { get; } // https://www.rfc-editor.org/rfc/rfc9000.html#name-stream-types-and-identifier
    public QuicStreamType Type { get; }  // https://github.com/dotnet/runtime/issues/55816, not necessary per se, CanRead and CanWrite should suffice

    // In 6.0 we had bool ReadsCompleted (tailored for ASP.NET) that would get set only in graceful case. The task gives the ability to distinguish error cases and is analogous to WritesCompleted.
    public Task ReadsClosed { get; } // gets set when STREAM frame with FIN bit (=EOF, =ReadAsync returning 0) is received or when the peer aborts the sending side by sending RESET_STREAM frame. Inspired by channel - might be ValueTask.
    
    // In 6.0 we had a method Task WaitForWriteCompletionAsync(). We need a Task that is removed from the operation kicking the write completion but gets completed when the sending side gets closed (either by CompleteWrites, endStream=true, Abort(Write) or Abort(Read) from the peer).
    public Task WritesClosed { get; } // gets set when the peer acknowledges STREAM frame with FIN bit (=EOF) or RESET_STREAM frame or when the peer aborts the receiving side by sending STOP_SENDING frame. Inspired by channel - might be ValueTask.

    // In 6.0 we had separate methods AbortRead and AbortWrite. This allows aborting both directions at the same time. It can remain split, it's not strictly necessary to have it combined.
    public void Abort(QuicAbortDirection abortDirection, long errorCode); // abortively ends either sending or receiving or both sides of the stream, i.e.: RESET_STREAM frame or STOP_SENDING frame

    // In 6.0 we had void Shutdown(), it was really badly named. The new name comes from DuplexStream API review, where CompleteWrites was chosen,
    public void CompleteWrites(); // https://github.com/dotnet/runtime/issues/43290, gracefully ends the sending side, equivalent to WriteAsync with endStream set to true.

    // Overload with endStream.
    public ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, bool completeWrites, CancellationToken cancellationToken = default);
}

[Flags]
enum QuicAbortDirection
{
   Read = 1,
   Write = 2,
   Both = Read | Write
}

public enum QuicStreamType
{
    Unidirectional,
    Bidirectional
}

API Usage

Client usage:

// Consider connection from https://github.com/dotnet/runtime/issues/68902:
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 sending-side.
await stream.WriteAsync(data, endStream: true, cancellationToken);
// or
await stream.WriteAsync(data, cancellationToken);
stream.CompleteWrites();

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

// DisposeAsync called by await using.

Server usage:

// Consider connection from https://github.com/dotnet/runtime/issues/68902:
await using var stream = await connection.AcceptStreamAsync(cancellationToken);

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

    // Peer send FIN flag with the last read.
    if (stream.ReadsCompleted.IsCompleted)
    {
        break;
    }
}

// Listen for Abort(Read) (STOP_SENDING) from the peer.
var writesCompletedTask = WritesCompletedAsync(stream);
async ValueTask WritesCompletedAsync(QuicStream stream)
{
    try
    {
        await stream.WritesCompleted;
    }
    catch (Exception ex)
    {
        // Abort the stream, peer send STOP_SENDING
    }
}

// DisposeAsync called by await using.

Alternative Designs

Read is finished

#57780
Currently we have a bool property, tailored to ASP.NET core needs, the property gets set only on graceful completion of the peer's sending side.

// Original. If it works... :)
public bool ReadsCompleted { get; }

// Proposal covers also abortive completions with details
public Task ReadsCompleted { get; } 

Sending EOF, getting notified about EOF

#43290
Currently we have:

// Gracefully ends the sending side, equivalent to endStream argument of WriteAsync
public void Shutdown();
// Doesn't kick off any operation leading to stream shutdown (for that Shutdown or Abort must be called). 
// Returns Task that gets completed when we gracefully end sending or we receive STOP_SENDING (i.e. peer called AbortRead).
public ValueTask WaitForWriteCompletionAsync(CancellationToken cancellationToken);

In #43290 (#43290 (comment)), this relevant part of API was approved:

// Shutdown renamed, in this proposal we have the same API.
public abstract void CompleteWrites();
// Task WritesCompleted property equivalent. Looks like async version of CompleteWrites but isn't, since it doesn't kick off any operation leading to stream sending completion.
public abstract ValueTask CompleteWritesAsync(CancellationToken cancellationToken = default);

Abortive completion

#756
Currently we have 2 methods:

public void AbortRead(long errorCode);
public void AbortWrite(long errorCode);

In #756 (#756 (comment)) we agreed on what this proposal has:

public void Abort(QuicAbortDirection abortDirection, long errorCode);

Stream type

#55816
We can use CanRead and CanWrite from stream as we do now, no need for any additional API. The only disadvantage is that they both return false after disposal, thus are not useful at that time. Whether that's a valid / expected usage is a different question.

Risks

As I'll state with all QUIC APIs. We might consider making all of these PreviewFeature. Not to deter user from using it, but to give us flexibility to tune the API shape based on customer feedback.
We don't have many users now and we're mostly making these APIs based on what Kestrel needs, our limited experience with System.Net.Quic and my meager experiments with other QUIC implementations.

Metadata

Metadata

Assignees

Labels

api-approvedAPI was approved in API review, it can be implementedarea-System.Net.QuicblockingMarks issues that we want to fast track in order to unblock other important work

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions