-
Notifications
You must be signed in to change notification settings - Fork 75
feat: add CloseRead/CloseWrite on streams #10
Conversation
This changes the behavior of `Close` to behave as one would expect: it closes the stream. The new methods, CloseWrite/CloseRead allow for closing the stream in a single direction. Note: This _does not_ implement CancelWrite/CancelRead as our stream muxer _protocols_ don't support that. fixes #9
cc @libp2p/go-team |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SGTM
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this is the right way to go.
Close
is the normal termination of a stream, i.e. should be called by the sender when all data has been sent. As such, it only makes sense to speak of closing the write side of a stream, since only the sender can know when it's done writing.
What we're really talking about here is the abnormal termination of a stream. Distinguishing between normal and abnormal stream termination makes a big difference as soon as you're using an unreliable transport, since you don't need to do retransmissions for terminated streams, whereas you need to ensure reliable delivery on streams that were normally terminated.
At a high level, there are two reasons why a stream can be abnormally terminated:
- The sender decides that it doesn't want to send the (full) reply any more.
- The receiver decides that it's not interested in the (full) reply any more.
Mixing these two unrelated use cases into one (and labeling it "reset") has caused us a lot of trouble in the past.
In QUIC we expose two methods, CancelWrite
and CancelRead
, which exactly correspond to the two abnormal termination conditions described above.
This proposal is based on the go standard library's TCPConn and UnixConn and the standard behavior of
Not really. In this proposal, To reliably close the stream and get confirmation that
I agree that This proposal is entirely about |
Design requirements as I see them:
Could you elaborate on this? I can't think of any cases where I'd want to abort reads or writes only and not just throw away the entire stream. |
The point is, once you break
We still want the transport to reliably deliver what we wrote, so the transport has to take care of this stream until all data has been acknowledged (in the case of TCP the kernel takes care of this, so in this case you can forget about the stream earlier). So what you're suggesting is more like send an EOF (i.e. normal stream termination in the send direction), combined with abnormal stream termination in the read direction.
This can work, but this API encourages sloppy programming. We really want a client to send us an EOF when it's done sending a request. However, we're encouraging it to not do so, since everything works if it never calls |
I get that. What if we just stated that
In the ideal case, yes. However,
I agree we should encourage users to use
At a minimum, I think we need to rename |
An instructive example is an HTTP POST to a site that doesn't exist. Say the client uploads a GB file, but the target on the server side has moved. In that case, the server would
There's an easy fix for that. You can implement a workaround
That's an argument about taste here. Streams don't implement the
Mostly because we've been lenient about EOFs for so long. If we define a request as "everything on the stream until EOF" instead of "the first x bytes that happen to be parseable", this wouldn't be a problem. A server wouldn't reply to a request until the stream was properly closed. A lot of helper functions in the Go standard library expect the EOF (e.g. |
I see. Personally, I'd just stop reading, write the error, then call However, I agree that always being able to cancel the write-side would be much better.
That's effectively what If that behavior is fine, then
They do in-fact implement the But my primary motivation here is the law of least surprise: users expect Let's break this down (as I see it). Is this correct? Naming Issues
Note: if we go with Protocol Issues What guarantees do we need around
Do we need a |
Before we did Is it correct to assume that now the process would be It's clear the current One advantange is that for bidirectional streams where one end writes a requests a reads an answer, it could call CloseWrite after sending the request. The receiving end would get EOF for reading so it would be known that the stream is closed for reading. Then it could call CloseWrite after sending the answer and fully terminate its stream. Am I getting it correctly that this approach would correctly clean up streams on both sides without additional calls to a full |
Yes.
Yes, ish.
That is the correct approach but I'd like to still require the However, we don't have to get rid of that hack, it's just kind of funky. I'm fine either way. |
Where In the request - response scenario, there are two clean ways to handle this would be:
The first option has the advantage that it immediately penalizes misbehaving peers. The second option performs slightly better in cases where the EOF arrives late due to network loss / reordering. Now going from a world where we have misbehaving implementations to a world where we force implementations to behave properly will require some careful thought. One option that comes to creating a new version of our stream multiplexers, be lenient with the old ones, and strict with the new ones. We might be able to come up with something better as well. |
I think the expectation is that |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Few comments, @Stebalien.
// CloseRead closes the stream for writing but leaves it open for | ||
// reading. | ||
// | ||
// CloseRead does not free the stream, users must still call Close or |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about a CloseRead()
followed by a CloseWrite()
? Should the contract enforce the release of resources?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A nice implementation should but I'm not sure if we want to require that. This is kind of like shutdown(SHUT_RD)
followed by shutdown(SHUT_WR)
, the user still needs to call close()
.
Let's dislodge this conversation. Stream interface refactors are traumatic, and we don't want to do these often. So let's make sure we're covering all concerns and potential use cases. @marten-seemann, aside from request-scoped streams, users also use streams for long-lived streaming (ignore the pun), and we also reuse streams in some places via pools (or may have the intent to). (Not all multiplexers can signal opening, data, closing in a single packet, like QUIC). Let's take a step back and consider the cases we're modelling for from the perspective of a libp2p user. For reference, here are my rough notes from researching this topic: https://gist.github.com/raulk/8db7e4cc7658ae9c9483aea1eed6b398. (TCP has no stream capability but its semantics are well implanted in people's brains so it's a fair reference for the principle of least surprise.)
I'm not done yet. I'm wrapping up my day and I wanted to post my notes here for the benefit of everybody (and also to collect possible corrections). Will continue tomorrow, and hopefully come back with something that reconciles the different points of view and interests. Questions:
|
Note: "stack makes an effort to deliver the bytes written before close is called but makes no guarantees because this is all done after Otherwise, that's a good summary. |
mux/mux.go
Outdated
// Reset. | ||
CloseWrite() error | ||
|
||
// CloseRead closes the stream for writing but leaves it open for |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wrong description (copypasta).
👉 Here's a matrix analysis of full-duplex, half-duplex, abrupt, graceful stream/connection closure across TCP, QUIC, Yamux and mplex. Highly suggest readers to use that as a reference.
I think this is wrong, it would be very bad if it works that way because it breaks TCP reliability. This is an active close, and at this stage, the connection would be in My conclusions to alter this changeset after this very thorough investigation:
|
Is best-effort up to some timeout (e.g., what the linux kernel does). Unfortunately, we can't guarantee anything unless we flush on close which will likely break a bunch of code that doesn't expect
👍
That is, go interfaces are implicit. Checking "does this thing have this function" isn't actually all that uncommon. (e.g., my own code: https://github.com/multiformats/go-multiaddr-net/blob/c9acf9f27c5020e78925937dc3de142d2d393cd1/net.go#L31-L77) On the other hand, go also uses Which options would you like to support? If it's just errors, maybe
I'd really just make it client-side, like it is with TCP and throw away any new data we get. In yamux, we can just never send a window update. Why? Well, usually we call
Many transports don't support abruptly closing one direction. I'm all for providing some way to feature-detect on a stream but I'd rather not add too many requirements that all transports need to meet. What's the motivation for abruptly closing the write side but not the read side? |
Go lib uses that to allow simulating custom error conditions. |
@raulk @Stebalien If this hasn't been allocated, I'd love to pick this up. |
This is waiting on a decision from @raulk. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about the following?
-
Close()
closes the write-side gracefully, and background-drains the read-side, unless the multiplexer supports cancelling reads (e.g. QUIC), in which case we send that signal. -
CloseRead()
andCloseWrite()
are not defined in standard interfaces AFAIK. As such, there is no special signature to respect.- We can offer
CloseRead(err)
andCloseWrite(err)
methods with no breakage of expectations. - Such signatures allow us to decorate the error and provide enhanced info to multiplexers.
- We can offer
-
Let's introduce a special error type.
type AnnotatedStreamError struct {
Reason int
}
-
On
CloseRead(nil)
: Yamux and mplex drain the receive end intoio.Discard
; on QUIC, we callCancelRead()
, with a 0 error code. -
On
CloseRead(AnnotatedStreamError)
: Yamux and mplex do the same as above, they do not support anything else. On QUIC, we callCancelRead()
, passing in the supplied error code. -
On
CloseWrite(nil)
: Yamux and mplex close the stream; on QUIC, we call [Close()
] (**notCancelWrite()
, see godocs), which translates to a graceful FIN. -
On
CloseWrite(AnnotatedStreamError)
: Yamux and mplex do the same as above, they do not support convey close reasons. On QUIC, we callCancelWrite()
, passing in the supplied error code. This signals an abrupt termination of the write-side. -
For errors types other than
AnnotatedStreamError
, we send a muxer-determined fixed value, which in the case of QUIC can be max(uint64).
Yes! 👍
We may want to use an interface (slightly more idiomatic). On the other hand, being overly generic doesn't get us too far. If we go with import "errors"
// could be stream/libp2p specific? CloseErrorCode? StreamErrorCode?
type ErrorCode interface {
ErrorCode() int
}
// we can provide a special wrapper type for convenience:
type CloseError struct {
Code int
Error error
}
/// error functions...
// extracts the error.
func getErrorCode(err error) int {
var errCode ErrorCode
if errors.As(err &errCode) {
return errCode.ErrorCode()
} else {
return SomeDefault
}
} |
Closing this and opening a new PR from the same branch to wipe the comment slate clean. |
fixes libp2p/go-libp2p-core#10 fix: avoid returning accept errors Instead, wait for shutdown.
fixes libp2p/go-libp2p-core#10 fix: avoid returning accept errors Instead, wait for shutdown.
fixes libp2p/go-libp2p-core#10 fix: avoid returning accept errors Instead, wait for shutdown.
This changes the behavior of
Close
to behave as one would expect: it closes the stream. The new methods, CloseWrite/CloseRead allow for closing the stream in a single direction.Note: This does not implement CancelWrite/CancelRead as our stream muxer protocols don't support that.
fixes #9