Skip to content
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

improve negotiation flushing #52

Merged
merged 2 commits into from Nov 11, 2020
Merged

improve negotiation flushing #52

merged 2 commits into from Nov 11, 2020

Conversation

Stebalien
Copy link
Member

First, this change exposes a Flush function so users can flush a handshake without writing or reading. We need this in libp2p to flush before closing for writing.

Second, this change flushes on Close. We can drop the read half of the handshake, but we need to send the write half. Otherwise, we could end up with the following situation:

  1. A: Send data.
  2. B: Receive data.
  3. B: Close the stream. (no flush)
  4. A: Wait for an EOF from the stream (ensure receipt of data).
  5. B: ERROR: the stream was closed but no multistream header was sent.

This is slightly unfortunate as Close should ideally never block. But close is allowed to flush and the alternative is to spawn a goroutine.

First, this change exposes a Flush function so users can flush a handshake without writing or
reading. We need this in libp2p to flush before closing for writing.

Second, this change flushes on Close. Otherwise, we could end
up with the following situation:

1. A: Send data.
2. B: Receive data.
3. B: Close the stream. (no flush)
4. A: Wait for an EOF from the stream (ensure receipt of data).
5. B: ERROR: the stream was closed but no multistream header was sent.
// Multistream represents in essense a ReadWriteCloser, or a single
// communication wire which supports multiple streams on it. Each
// stream is identified by a protocol tag.
type Multistream interface {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replacing this with an explicit LazyConn is technically breaking, but nothing uses it (and the interface was quite useless...).

multistream.go Outdated
@@ -201,7 +209,7 @@ func (msm *MultistreamMuxer) findHandler(proto string) *Handler {
// a multistream, the protocol used, the handler and an error. It is lazy
// because the write-handshake is performed on a subroutine, allowing this
// to return before that handshake is completed.
func (msm *MultistreamMuxer) NegotiateLazy(rwc io.ReadWriteCloser) (io.ReadWriteCloser, string, HandlerFunc, error) {
func (msm *MultistreamMuxer) NegotiateLazy(rwc io.ReadWriteCloser) (LazyConn, string, HandlerFunc, error) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is breaking, and it's a problem. Currently, go-libp2p-core expects this to return an io.ReadWriteCloser.

However, there is no explicit dependency relationship between this module and go-libp2p-core (intentionally).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I've reverted this. I'm going to do type assertions in go-libp2p where necessary.

Unfortunately, go-libp2p relies on this interface (for now).
@petar
Copy link

petar commented Oct 28, 2020

A few thoughts:

  • Blocking on Close in the client is not an issue in my opinion.
    Close is a write-type operation and in general it is blocking (in various other implementations of io.Closer).

  • If the client is used correctly, in that Close is always called, then every connection goes through
    a handshake, which seems to defeat the purpose of the "lazy" (i.e. handshake triggered only on use of the connection) logic.
    You might as well call handshake on start (in the client)?

  • I am suspecting a race condition:
    (1) Call Write.
    (1a) Triggers read handshake in a goroutine
    (1b) Triggers and waits for a write handshake
    (1c) Assume read handshake has not completed by the time (1b) completes.
    (2) Call Read.
    (2a) Read handshake is not triggered (because it is already started)
    (2b) Proceed to reading from the connection --> this races with the ongoing read handshake.

@Stebalien
Copy link
Member Author

If the client is used correctly, in that Close is always called, then every connection goes through
a handshake, which seems to defeat the purpose of the "lazy" (i.e. handshake triggered only on use of the connection) logic.

The goal of the "lazy" handshake is actually:

  1. I can start writing before the handshake completes.
  2. I can send the multistream headers and the first message in the same packet.

In general, I don't expect users to open streams until they actually need to send data, so I'm not so concerned about always handshaking. We could walk away if we've never used the stream (never including never reading), but I'm not sure if this is worth the complexity for such an edge case.

I am suspecting a race condition:
...
(2b) Proceed to reading from the connection --> this races with the ongoing read handshake.

This is protected by a sync.Once (l.rhandshakeOnce).

@Stebalien
Copy link
Member Author

Stebalien commented Oct 28, 2020

Blocking on Close in the client is not an issue in my opinion.
Close is a write-type operation and in general it is blocking (in various other implementations of io.Closer).

I generally agree. Although usually we just close and flush async. The guarantee is: if you want to make sure the other side receives everything, call CloseWrite, then call Read to wait for an EOF. Close() acts like a normal unix TCP "close".

@petar
Copy link

petar commented Oct 29, 2020

@Stebalien In (2b), I mean that the ongoing "read handshake" races with https://github.com/multiformats/go-multistream/blob/master/lazyClient.go#L75

@petar
Copy link

petar commented Oct 29, 2020

Although usually we just close and flush async. The guarantee is: if you want to make sure the other side receives everything, call CloseWrite, then call Read to wait for an EOF. Close() acts like a normal unix TCP "close".

If you want to stick to this convention (which also seems fine), is there anything preventing you from doing it simply, e.g. like:

func (l *lazyClientConn) Close() error {
  go func() {
         _ = l.Flush()
	l.werr = l.con.Close() // sync this write somehow
  }()
  return nil
}

@Stebalien
Copy link
Member Author

Read will block on the ongoing read handshake here:

l.rhandshakeOnce.Do(func() {

We always run the read handshake under this once:

go l.rhandshakeOnce.Do(l.doReadHandshake)

@Stebalien
Copy link
Member Author

#52 (comment)

  • I'd prefer to block than potentially oom due to launching a bunch of goroutines.
  • We'd need to somehow prevent reads/writes after calling Close from blocking and/or succeeding. With that code, I'd be able to call Close() then read data with Read().

There's no reason we absolutely can't do it I just think the current solution is the lesser of two evils.

Copy link

@aschmahmann aschmahmann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a comment, this makes sense to me. Although my familiarity with this part of the code base and it's rough edges isn't as good as I'd like it to be.

@raulk any thoughts before @Stebalien starts moving this along through the stack?


@Stebalien do you mind if while we propagate these changes through go-libp2p (and go-ipfs) we use stacks of PRs instead of merging + releasing at each step? It'll give us the most test coverage since we have test coverage throughout the stack,

lazyClient.go Show resolved Hide resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants