-
Notifications
You must be signed in to change notification settings - Fork 4.8k
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
Feature Request: Add ability to explicitly send EOF for connected Streams #43290
Comments
I couldn't figure out the best area label to add to this issue. If you have write-permissions please help me learn by adding exactly one area label. |
@scalablecory Is this required by HTTP3? I.e. do we need to be able to shutdown writes on the QUIC stream as part of sending the HTTP3 request? |
@geoffkizer yes QUIC needs both a The 2nd one is important to QUIC because it causes the data and the FIN bit to be sent in a single packet. (Note: QUIC needs a whole lot more too, so it will need its own abstraction anyway. I think we could live without the 2nd on |
Good point -- I added some text at the end about this in the proposal. |
Things like lengthless HTTP/1.0 requests can't be implemented over TLS without this. |
Our other stream type |
"Complete" always strikes me as awkward because it's not clear what you are completing... it seems to imply that previous writes are not "complete" somehow until I call this. Another option here would be "CloseWrite". |
One quirk I've ran into while prototyping this is how it behaves when shutdown is not supported. Say I'm taking a generic If we make the default implementation do nothing, my code might block indefinitely waiting for a response that will never come. Similarly, if we make the default implementation throw, I won't discover things will break until I'm at the end of a potentially very large write operation. Here is a pattern that does work:
One thing I've tried that does not work is to introduce a |
@stephentoub I'd love to see this in .NET 6 -- are you okay bringing to API review? |
What is the proposed API at this point? I see open questions and varying ideas without a solidified answer. |
I've done some prototyping here and would propose the API as: enum FlushMode
{
None,
FlushWrites,
FlushAndShutdownWrites
}
class Stream
{
public virtual bool CanShutdownWrites { get; }
public virtual void Flush(FlushMode flushMode);
public virtual ValueTask FlushAsync(FlushMode flushMode, CancellationToken cancellationToken = default);
public virtual void Write(ReadOnlySpan<byte> buffer, FlushMode flushMode);
public virtual ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, FlushMode flushMode, CancellationToken cancellationToken);
} The I think The Write overloads are stretch goals here. They'd be useful for a couple optimizations things:
Default implementation would look like: public virtual bool CanShutdownWrites { get; } => false;
public virtual void Flush(FlushMode flushMode)
{
if(flushMode == FlushMode.FlushWrites) Flush();
else if(flushMode == FlushMode.FlushAndShutdownWrites) throw new IOException("stream does not support shutdown.");
}
public virtual ValueTask FlushAsync(FlushMode flushMode, CancellationToken cancellationToken = default) =>
flushMode == FlushMode.FlushWrites => new ValueTask(FlushAsync(cancellationToken)) :
flushMode == FlushMode.FlushAndShutdownWrites => ValueTask.FromException(new IOException("stream does not support shutdown.")) :
cancellationToken.IsCancellationRequested => ValueTask.FromCanceled(cancellationToken) :
default;
public virtual void Write(ReadOnlySpan<byte> buffer, FlushMode flushMode)
{
Write(buffer);
Flush(flushMode);
}
public virtual ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, FlushMode flushMode, CancellationToken cancellationToken)
{
ValueTask writeTask = WriteAsync(buffer, cancellationToken);
return flushMode == FlushMode.None
? writeTask
: FinishWriteAndFlushAsync(writeTask, this, flushMode, cancellationToken);
static async ValueTask FinishWriteAndFlushAsync(ValueTask writeTask, Stream stream, FlushMode flushMode, CancellationToken cancellationToken)
{
await writeTask.ConfigureAwait(false);
await stream.FlushAsync(flushMode, cancellationToken).ConfigureAwait(false);
}
} Wrapper streams without buffering like class MyCustomStream : Stream
{
readonly Stream _baseStream;
public override bool CanShutdownWrites =>
_baseStream.CanShutdownWrites;
public override void Flush(FlushMode flushMode) =>
_baseStream.Flush(flushMode);
public override ValueTask FlushAsync(FlushMode flushMode, CancellationToken cancellationToken) =>
_baseStream.FlushAsync(flushMode, cancellationToken);
} With buffering would look like: class MyCustomStream : Stream
{
readonly Stream _baseStream;
readonly Buffer _buffer;
public override bool CanShutdownWrites =>
_baseStream.CanShutdownWrites;
public override void Flush(FlushMode flushMode)
{
if(flushMode == FlushMode.None)
{
return;
}
if(_buffer.ActiveLength != 0)
{
_baseStream.Write(_buffer.ActiveSpan, flushMode);
}
else
{
_baseStream.Flush(flushMode);
}
}
public override ValueTask FlushAsync(FlushMode flushMode, CancellationToken cancellationToken) =>
_buffer.ActiveLength != 0 ? _baseStream.WriteAsync(_buffer.ActiveMemory, flushMode, cancellationToken) :
_baseStream.FlushAsync(flushMode, cancellationToken);
} |
@scalablecory maybe instead of adding flags specific for flushing, we can add a more generic [Flags]
enum WriteFlags
{
}
class Stream
{
public virtual void Write(ReadOnlySpan<byte> buffer, WriteFlags writeFlags);
public virtual ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, WriteFlags writeFlags, CancellationToken cancellationToken);
} |
@tmds Yeah, I thought of that too. If we can find any additional flags we might want, lets consider them. I can't think of any off the top of my head. Adding it as a future extensibility point is dubious, as any future flags would not have to break anyone who implemented it before the new flag was added. |
Can see a prototype being used of my proposal here: |
I am not sure we are adding any value with the Flush overloads. Flush(None) doesn't make any sense. |
I'm very hesitant to add new Write overloads that also implicitly flush. Now every time we add new write APIs (e.g. vector I/O), are we going to overload for flushing, or have additional defaulted flush arguments, or some such things? |
To be clear -- are you objecting specifically to passing FlushWrites to Write, or also FlushAndShutdownWrites too? |
My comment was specific to adding new Write overloads that take a flush enum, in particular because then it seems we basically need to add new WriteXx overloads that take the enum for existing write methods and future write methods. |
One alternative (as described in the original post here) is to have this be a bool that has nothing to do with flush specifically: public virtual Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken, bool shutdownWrite); But I assume your objection applies to this alternative as well, for the same reason. |
I'm defaulting the flush argument for the vector Write and it's working great. But yes, we could get by without Write overloads if needed. It just won't be as efficient. |
It's a more intuitive API. Shutdown implies flush, and this makes that clear. It gets rid of the question (by both implementers and users) "I called shutdown, should I have called flush too?" A
Agreed; None is only there to support the Write overload. Might make sense to split the enum into two, but I've found value (see the prototype code linked) in having them both use the same enum.
I would expect this to work for any implementation, but it feels very bad to suggest that be THE way to shutdown. |
Seems like we should also discuss this issue at the same time: #44782 |
I agree with this, but I'd suggest exposing a simple ShutdownWrites() call instead of exposing it through Flush. We want auto-flush streams like NetworkStream and SslStream to support sending EOF. Currently when I use those streams, I ignore Flush completely. It seems weird to me that we'd now say, use Flush to send EOF. |
I'm having trouble seeing the downside here, only upside. I guess your argument is that Shutdown should not imply Flush. What do you think behavior should be on a Stream if you call Shutdown without having a flushed? |
You mean call ShutdownWrite+Dispose? If you mean just ShutdownWrite and not Dispose, we can't do that.
Other Can methods that return false mean that the corresponding operations fail when used. This one apparently doesn't. That's not a deal breaker, but it's an unfortunate inconsistency. What code will use this property? What will a typical decision based on it look like? |
Yes.
That's a good point. I don't think that'd be bad behavior, honestly.
void MakeHttp10Request(Stream stream)
{
if(!stream.CanShutdownWrite) throw new Exception("HTTP/1.0 requires the ability to shutdown writes on a Stream");
}
void MakeHttp30Request(Stream stream)
{
if(!stream.CanShutdownWrite) throw new Exception("HTTP/3.0 requires the ability to shutdown writes on a Stream");
} |
Throwing from ShutdownWrite in the base implementation? What would all of those call sites you wanted to update do then? If they'd all be required to make the virtual Can call and then fallback themselves to Flush, that seems similarly less than ideal.
What would PipeStream for example return from CanShutdownWrite? We wouldn't let it be used in SocketsHttpHandler just because it doesn't support shutting down just one direction? |
Yeah, that's a good point. I'm worried that if you need proper shutdown semantics and you forget to check
It just means we wouldn't let it be used with HTTP/1.0 with a lengthless request, as there is no chunked encoding to denote EOF and without EOF the other side won't know when request content ends, to start sending the response. |
We felt that the concept really warranted introducing a new middle type to the hierarchy: namespace System.IO
{
public abstract class DuplexStream : Stream
{
// when disposed, this write-only stream will call CompleteWrites().
// this allows compat with e.g. StreamWriter that knows nothing about shutdown.
public Stream GetWriteOnlyStream() => throw null;
public abstract void CompleteWrites();
public abstract ValueTask CompleteWritesAsync(CancellationToken cancellationToken = default);
override all the things;
}
}
partial class NetworkStream : DuplexStream
{
}
and other streams as needed. (I edited so that NetworkStream derives from DuplexStream, not BidirectionalStream) |
The name |
@campersau looking here (thanks @stephentoub for showing me this wonderful tool): https://grep.app/search?q=class%20DuplexStream%20&filter[lang][0]=C%23 I think we're okay. There appear to be very few, and all of them are internal types. Almost all of them are ASP.NET or YARP (CC @Tratcher) that we can get fixed easily enough. What's left is @jstedfast's MailKit and a couple unmaintained and/or Framework-only repos. |
Background and Motivation
Connected Streams -- like NetworkStream, PipeStream, SslStream, and the new QuicStream class -- need the ability to send EOF to indicate that the write side has completed without closing the Stream (and thus closing the read side too).
At the Socket layer, we support this via Socket.Shutdown(SocketShutdown.Send). We don't expose this in NetworkStream, however.
This is most useful for "connected" Streams that can Read and Write but not Seek. It's not clear whether this concept applies to streams like FileStream that can Seek.
Proposed API
Based on comments from this issue: #43101
The basic idea is to add an API like the following:
We'd like naming guidance:
The hard question is where this API lives. There are three options here:
Add a new abstract base class
ConnectedStream
(or whatever -- naming suggestions welcome) that derives from Stream and that our various existing streams inherit where appropriate (e.g. NetworkStream, SslStream, PipeStream, QuicStream, possibly others).GzipStream
that wrap a base stream and would be appropriate to pass-through a shutdown request no longer can.Just add a new virtual method to Stream itself, ideally with a reasonable default implementation, e.g. just calling Dispose (if there's no good default behavior, then potentially also a Can/Is capability test property).
Add a mix-in via an interface like
IConnectedStream
that can be tested for, e.g.if (stream is IConnectedStream cs) cs.ShutdownWrites();
We'd like naming guidance: Shutdown()
Another issue to consider is whether to provide an overload for Write/WriteAsync that allows you to pass a flag indicating that the Stream should send EOF after the specified buffer, e.g. something like this:
This is important because it enables the data and EOF to be sent in a single packet, which can improve performance, specifically for QUIC (and HTTP3 over QUIC) as well as HTTP2 request streams.
If we add a WriteAsync overload like this, then we could opt not to do ShutdownWrite at all and instead achieve this by passing a 0-length buffer and shutdownWrite=true to the WriteAsync overload.
cc @scalablecory @stephentoub
The text was updated successfully, but these errors were encountered: