-
Notifications
You must be signed in to change notification settings - Fork 4.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[API Proposal]: [QUIC] QuicStream #69675
Comments
Tagging subscribers to this area: @dotnet/ncl Issue DetailsBackground and motivationAPI design for exposing The API shape is mainly defined by 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 This API proposal concentrates on the additional, QUIC specific APIs. The whole API shape is in details at the end. API Proposalpublic class QuicStream : Stream
{
// Stream API implied, QUIC specifics follow:
public long StreamId { get; } // https://www.rfc-editor.org/rfc/rfc9000.html#name-stream-types-and-identifier
public StreamDirection { get; } // https://github.com/dotnet/runtime/issues/55816
public Task ReadsCompleted { get; } // gets set when peer sends STREAM frame with FIN bit (=EOF, =ReadAsync returning 0) or when peer aborts the sending side by sending RESET_STREAM frame
public Task WritesCompleted { get; } // gets set our side sends STREAM frame with FIN bit (=EOF) or when peer aborts the receiving side by sending STOP_SENDING frame
public void Abort(Read|Write flag, long errorCode); // abortively ends either sending ore receiving or both sides of the stream, i.e.: RESET_STREAM frame or STOP_SENDING frame
public void CompleteWrites(); // https://github.com/dotnet/runtime/issues/43290, gracefully ends the sending side, equivalent to WriteAsync with endStream set to true
// New overloads for WriteAsync, each of them introduces overload with bool endStream.
// Used by ASP.NET core.
public ValueTask WriteAsync(ReadOnlySequence<byte> buffers, CancellationToken cancellationToken);
public ValueTask WriteAsync(ReadOnlySequence<byte> buffers, bool endStream, CancellationToken cancellationToken);
// Used by HttpClient.
public ValueTask WriteAsync(ReadOnlyMemory<ReadOnlyMemory<byte>> buffers, CancellationToken cancellationToken);
public ValueTask WriteAsync(ReadOnlyMemory<ReadOnlyMemory<byte>> buffers, bool endStream, CancellationToken cancellationToken);
// Overload with endStream for the one inherited from Stream.
public ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, bool endStream, CancellationToken cancellationToken); API UsageTODO Alternative DesignsNo response RisksNo response
|
namespace System.Net.Quic;
public sealed class QuicStream : Stream
{
public long StreamId { get; }
public QuicStreamType StreamType { get; }
public Task ReadsCompleted { get; }
public Task WritesCompleted { get; }
public void Abort(QuicAbortDirection abortDirection, long errorCode);
public void CompleteWrites();
public ValueTask WriteAsync(ReadOnlySequence<byte> buffers, CancellationToken cancellationToken);
public ValueTask WriteAsync(ReadOnlySequence<byte> buffers, bool endStream, CancellationToken cancellationToken);
public ValueTask WriteAsync(ReadOnlyMemory<ReadOnlyMemory<byte>> buffers, CancellationToken cancellationToken);
public ValueTask WriteAsync(ReadOnlyMemory<ReadOnlyMemory<byte>> buffers, bool endStream, CancellationToken cancellationToken);
public ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, bool endStream, CancellationToken cancellationToken);
}
[Flags]
public enum QuicAbortDirection
{
Read = 1,
Write = 2,
Both = Read | Write
}
public public enum QuicStreamType
{
Unidirectional,
Bidirectional
} |
API Review CommentsStreamId
ReadsCompleted and WritesCompleted is "inverted" as it refers to the state of the other side
WriteAsync
The terminology of unidirectional is a bit confusing because it seems to imply writing
Side notesI also investigated behavior of Testing code: class Program
{
static async Task Main(string[] args)
{
await Task.WhenAll(RunServer(), RunClient());
}
static readonly TaskCompletionSource<IPEndPoint> serverEndpoint = new TaskCompletionSource<IPEndPoint>();
static readonly TaskCompletionSource clientWrite = new TaskCompletionSource();
static async Task RunClient()
{
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
socket.NoDelay = true;
var endpoint = await serverEndpoint.Task;
Console.WriteLine("Client connecting to: " + endpoint);
socket.Connect(endpoint);
Console.WriteLine("Client connected to: " + socket.RemoteEndPoint);
var stream = new NetworkStream(socket, ownsSocket: true);
for (int i = 0; i < 1_000; ++i)
{
await stream.WriteAsync(UTF8Encoding.UTF8.GetBytes("Ahoj"));
}
clientWrite.SetResult();
/*try
{
// Read will throw "Connection reset by peer".
await stream.ReadAsync(new byte[10]);
}
catch (Exception ex)
{
Console.WriteLine(ex);
}*/
socket.Shutdown(SocketShutdown.Send);
stream.Dispose();
}
static async Task RunServer()
{
var listenSocket = new Socket(SocketType.Stream, ProtocolType.Tcp);
listenSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
listenSocket.Listen();
serverEndpoint.SetResult(listenSocket.LocalEndPoint as IPEndPoint);
Console.WriteLine("Server listening on: " + listenSocket.LocalEndPoint);
var socket = await listenSocket.AcceptAsync().ConfigureAwait(false);
socket.NoDelay = true;
// To force RST when calling Close.
socket.LingerState = new LingerOption(true, 0);
var stream = new NetworkStream(socket, ownsSocket: true);
var buffer = new byte[100];
int readBytes = 0;
do
{
readBytes = await stream.ReadAsync(buffer);
Console.WriteLine($"Server({readBytes}):" + UTF8Encoding.UTF8.GetString(buffer, 0, readBytes));
await clientWrite.Task;
socket.Close(0);
} while (false);//(readBytes > 0);
}
} |
namespace System.Net.Quic;
public sealed class QuicStream : Stream
{
public long Id { get; }
public QuicStreamType Type { get; }
public Task ReadsClosed { get; }
public Task WritesClosed { get; }
public void Abort(QuicAbortDirection abortDirection, long errorCode);
public void CompleteWrites();
public ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, bool completeWrites, CancellationToken cancellationToken = default);
}
[Flags]
public enum QuicAbortDirection
{
Read = 1,
Write = 2,
Both = Read | Write
}
public enum QuicStreamType
{
Unidirectional,
Bidirectional
} |
I find it's quite unexpected to use I thought the conclusion of the discussion on #55485 was to do nothing on cancellation. With the assumption that the application will typically catch the exception and take action to either abort the write/read side of the stream (or possibly even dispose it). Aborting the stream internally with |
That's actually a very good point @bentoi. The way to supply your own code is to call Abort explicitly. But if you register it on the same cancellation token you pass to e.g. WriteAsync, I don't think we give any guarantee on the order in which cancellation callbacks are called (from reverse engineering CT code, they will be called in stack-like order) -- so that will not work. The kind of super ugly workaround I can think of, is to have a second CT, that would first call abort with your code and then trigger the first CTS (which token is passed to the method) 🙈 |
The idea behind #55485 was to either have soft cancellation - you can still use the stream afterwards; or abort on cancellation - what we do now. Neither solution is perfect, the question is which one is better for most of the users:
What we had in .NET until now, not aborting but also not allowing any subsequent operation, seems like the least expected behavior, so I'd rather not bring it back. |
To me, when dealing with a read or write operation on a socket, an IO stream or a Quic stream, the expectation is that if the operation raises an exception, there isn't much you can do after other than aborting/disposing the socket/stream. It's in particular true for writes where you can't figure out how much data was actually written before the exception is raised. So a soft cancellation with the expectation that the application will abort the stream read/write side would be fine. I believe that's how other APIs such as Socket or IO stream also work. Can't the |
That's what we did before and what we were not happy about. From the user perspective, you're not allowed to use the stream (particular side only) after cancellation, while from the protocol perspective, nothing is wrong with the stream. Moreover, the users would need to know that they should abort afterwards, otherwise we might actually close the writing side gracefully with dispose. But we can discuss more the soft cancellation option and reconsider the current solution. |
I'm not using the Quic API yet but I would like to use it once it's released with .NET Core 7.0. I'm planning to use it for an application protocol similar to HTTP/3. For now, I'm using a TCP based transport similar to HTTP/2. Cancellation of a request aborts the underlying stream with a specific error code (like So basically, right now I'm using something like the following:
I can re-arrange the code to instead do something like the following:
It's not more complicated so I'll use this instead. However, I find that using the same default error code for different purposes isn't right. That's why I brought this up and questioned whether or not soft cancellation was a better approach. But I see your point about implicitly aborting the stream on cancellation. |
@bentoi thanks for the examples! We'll certainly take your concerns and use cases into account and I'll re-open this discussion with my team. |
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 ofQuicStream
to be usable as an ordinary stream, e.g.: allow usage like this: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 wrapQuicStream
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
API Usage
Client usage:
Server usage:
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.Sending EOF, getting notified about EOF
#43290
Currently we have:
In #43290 (#43290 (comment)), this relevant part of API was approved:
Abortive completion
#756
Currently we have 2 methods:
In #756 (#756 (comment)) we agreed on what this proposal has:
Stream type
#55816
We can use
CanRead
andCanWrite
from stream as we do now, no need for any additional API. The only disadvantage is that they both returnfalse
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.
The text was updated successfully, but these errors were encountered: