-
Notifications
You must be signed in to change notification settings - Fork 23
fix(network): close idle peer connections, back off on already connected, lower maxbackoff #4467
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
base: master
Are you sure you want to change the base?
Changes from all commits
3595592
11fc3d3
19ebb69
9864b4d
87bd74f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import ( | |
| "io" | ||
| "sync" | ||
| "sync/atomic" | ||
| "time" | ||
|
|
||
| "github.com/nuts-foundation/nuts-node/v6/network/log" | ||
| "github.com/nuts-foundation/nuts-node/v6/network/transport" | ||
|
|
@@ -86,20 +87,32 @@ type Connection interface { | |
|
|
||
| // closeError returns the status when the connection closed with an error or nil otherwise | ||
| closeError() *status.Status | ||
| // waitForReceivers blocks until all receive loops have exited, which makes closeError() final. | ||
| // The underlying streams must be closed first, otherwise the receive loops block forever. | ||
| waitForReceivers() | ||
| } | ||
|
|
||
| func createConnection(parentCtx context.Context, peer transport.Peer) Connection { | ||
| func createConnection(parentCtx context.Context, peer transport.Peer, idleTimeout time.Duration) Connection { | ||
| result := &conn{ | ||
| streams: make(map[string]Stream), | ||
| outboxes: make(map[string]chan interface{}), | ||
| streams: make(map[string]Stream), | ||
| outboxes: make(map[string]chan interface{}), | ||
| idleTimeout: idleTimeout, | ||
| } | ||
| result.ctx, result.cancelCtx = context.WithCancel(parentCtx) | ||
| result.setPeer(peer) | ||
| return result | ||
| } | ||
|
|
||
| type conn struct { | ||
| peer atomic.Value | ||
| peer atomic.Value | ||
| // idleTimeout is the period without any received message after which the connection is closed. Zero disables the check. | ||
| idleTimeout time.Duration | ||
| // lastReceived holds the time (unix nanoseconds) a message was last received on any of the connection's streams. | ||
| lastReceived atomic.Int64 | ||
| // receivers tracks the receive loops, so callers can wait for the close status to be final. | ||
| receivers sync.WaitGroup | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new |
||
| // handling counts the receive loops that are currently handling a message; the idle timeout does not apply while handling. | ||
| handling atomic.Int32 | ||
| ctx context.Context | ||
| cancelCtx func() | ||
| status atomic.Pointer[status.Status] | ||
|
|
@@ -199,6 +212,11 @@ func (mc *conn) registerStream(protocol Protocol, stream Stream) bool { | |
| return false | ||
| } | ||
|
|
||
| if len(mc.streams) == 0 && mc.idleTimeout > 0 { | ||
| // first stream on this connection: start watching for idleness | ||
| mc.lastReceived.Store(time.Now().UnixNano()) | ||
| mc.watchIdle() | ||
| } | ||
| mc.streams[methodName] = stream | ||
| mc.outboxes[methodName] = make(chan interface{}, OutboxHardLimit) | ||
|
|
||
|
|
@@ -217,35 +235,49 @@ func (mc *conn) registerStream(protocol Protocol, stream Stream) bool { | |
| func (mc *conn) startReceiving(protocol Protocol, stream Stream) { | ||
| peer := mc.Peer() // copy Peer, because it will be nil when logging after disconnecting. | ||
| atomic.AddInt32(&mc.activeGoroutines, 1) | ||
| mc.receivers.Add(1) | ||
| go func(activeGoroutines *int32) { | ||
| defer atomic.AddInt32(activeGoroutines, -1) | ||
| defer mc.receivers.Done() | ||
| for { | ||
| message := protocol.CreateEnvelope() | ||
| err := stream.RecvMsg(message) // blocking | ||
| if mc.ctx.Err() != nil { | ||
| // connection has been closed: drop message and stop receiving | ||
| return | ||
| } | ||
| if err != nil { | ||
| errStatus, isStatusError := status.FromError(err) | ||
| if errors.Is(err, io.EOF) || (isStatusError && errStatus.Code() == codes.Canceled) { | ||
| log.Logger(). | ||
| WithField(core.LogFieldProtocolVersion, protocol.Version()). | ||
| WithFields(peer.ToFields()). | ||
| Info("Peer closed connection") | ||
| } else { | ||
| log.Logger(). | ||
| WithError(err). | ||
| WithField(core.LogFieldProtocolVersion, protocol.Version()). | ||
| WithFields(peer.ToFields()). | ||
| Warn("Peer connection error") | ||
| closedByPeer := !errors.Is(err, io.EOF) && !(isStatusError && errStatus.Code() == codes.Canceled) | ||
| if mc.ctx.Err() == nil { | ||
| // only log when the connection wasn't closed locally | ||
| if closedByPeer { | ||
| log.Logger(). | ||
| WithError(err). | ||
| WithField(core.LogFieldProtocolVersion, protocol.Version()). | ||
| WithFields(peer.ToFields()). | ||
| Warn("Peer connection error") | ||
| } else { | ||
| log.Logger(). | ||
| WithField(core.LogFieldProtocolVersion, protocol.Version()). | ||
| WithFields(peer.ToFields()). | ||
| Info("Peer closed connection") | ||
|
Comment on lines
+257
to
+260
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we should log this on info, as its regular application flow? Also, the if-statement flow ( |
||
| } | ||
| } | ||
| if closedByPeer { | ||
| // Record the peer's close status even if the connection was already cancelled (e.g. because the stream's context is done), | ||
| // so the caller can decide whether the peer rejected the connection. | ||
| mc.status.Store(errStatus) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this also executed when there's no error, but just the remote server shutting down? |
||
| } | ||
| mc.status.Store(errStatus) | ||
| mc.cancelCtx() | ||
| break | ||
| } | ||
| if mc.ctx.Err() != nil { | ||
| // connection has been closed: drop message and stop receiving | ||
| return | ||
| } | ||
| mc.lastReceived.Store(time.Now().UnixNano()) | ||
|
|
||
| mc.handling.Add(1) | ||
| err = protocol.Handle(mc, message) | ||
| mc.handling.Add(-1) | ||
| mc.lastReceived.Store(time.Now().UnixNano()) // handling a message counts as activity as well | ||
| if err != nil { | ||
| log.Logger(). | ||
| WithError(err). | ||
|
|
@@ -258,6 +290,42 @@ func (mc *conn) startReceiving(protocol Protocol, stream Stream) { | |
| }(&mc.activeGoroutines) | ||
| } | ||
|
|
||
| // watchIdle disconnects the connection when no message has been received within idleTimeout. | ||
| // Peers send gossip and diagnostics messages at a fixed interval, so a silent stream is a dead one | ||
| // (e.g. a half-open TCP connection or a proxy that kept the stream open after the other side went away). | ||
| func (mc *conn) watchIdle() { | ||
| peer := mc.Peer() // copy Peer, because it will be reset by disconnect() | ||
| atomic.AddInt32(&mc.activeGoroutines, 1) | ||
| go func(activeGoroutines *int32) { | ||
| defer atomic.AddInt32(activeGoroutines, -1) | ||
| timer := time.NewTimer(mc.idleTimeout) | ||
| defer timer.Stop() | ||
| for { | ||
| select { | ||
| case <-mc.ctx.Done(): | ||
| return | ||
| case <-timer.C: | ||
| if mc.handling.Load() > 0 { | ||
| // still busy handling a message (e.g. a large transaction list during sync), which is not idle | ||
| timer.Reset(mc.idleTimeout) | ||
| continue | ||
| } | ||
| idle := time.Since(time.Unix(0, mc.lastReceived.Load())) | ||
| if idle < mc.idleTimeout { | ||
| timer.Reset(mc.idleTimeout - idle) | ||
| continue | ||
| } | ||
| log.Logger(). | ||
| WithFields(peer.ToFields()). | ||
| WithField("idle", idle.Round(time.Second)). | ||
| Warn("No messages received from peer within idle timeout, disconnecting") | ||
| mc.disconnect() | ||
| return | ||
| } | ||
| } | ||
| }(&mc.activeGoroutines) | ||
| } | ||
|
|
||
| func (mc *conn) startSending(protocol Protocol, stream Stream) { | ||
| outbox := mc.outboxes[protocol.MethodName()] | ||
|
|
||
|
|
@@ -321,3 +389,7 @@ func (mc *conn) IsAuthenticated() bool { | |
| func (mc *conn) closeError() *status.Status { | ||
| return mc.status.Load() | ||
| } | ||
|
|
||
| func (mc *conn) waitForReceivers() { | ||
| mc.receivers.Wait() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -108,7 +108,7 @@ func NewGRPCConnectionManager(config Config, connectionStore stoabs.KVStore, nod | |
| authenticator: authenticator, | ||
| config: config, | ||
| connectionTimeout: config.connectionTimeout, | ||
| connections: &connectionList{}, | ||
| connections: &connectionList{idleTimeout: config.idleTimeout}, | ||
| dialer: config.dialer, | ||
| dialOptions: []grpc.DialOption{ | ||
| grpc.WithBlock(), // Dial should block until connection succeeded (or time-out expired) | ||
|
|
@@ -462,9 +462,17 @@ func (s *grpcConnectionManager) openOutboundStreams(connection Connection, grpcC | |
| // Function must block until streams are closed or disconnect() is called. | ||
| connection.waitUntilDisconnected() | ||
|
|
||
| if st := connection.closeError(); st != nil && st.Code() == codes.Unauthenticated { | ||
| // return error so entire connection will be tried anew. Otherwise, backoff isn't honored | ||
| return st.Err() | ||
| // Close the gRPC connection so blocked receive loops return, then wait for them: | ||
| // only then is the close status (as sent by the peer) final. | ||
| _ = grpcConn.Close() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| connection.waitForReceivers() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can this hang indefinetly, and should we guard against that? |
||
|
|
||
| if st := connection.closeError(); st != nil { | ||
| // Peer rejected the connection: return the error so the backoff is honored instead of reconnecting within seconds. | ||
| // ErrAlreadyConnected arrives as codes.Unknown (plain error returned by the peer's stream handler), so match on the message. | ||
| if st.Code() == codes.Unauthenticated || st.Message() == ErrAlreadyConnected.Error() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return st.Err() | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
|
|
||
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.
isn't it more semantic to store time.Time with
atomic.Value[time.Time]as type?