mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-09 00:11:28 +02:00
[client] Sweep connections on network loss via a shared netevents manager
Losing the last network only flipped the availability state: the dead management, signal and relay sockets stayed silently connected until their own timeouts, so the client kept reporting Connected with no network at all. Introduce client/netevents with a Manager that ties the availability state, the connection sweeper and the status recorder together, and move the netstate and netsweep packages under it (netsweep renamed to sweep). SetNetworkAvailable(false) now also sweeps the registered connections so their owners redial and the listener reaches the NoNetwork state. The Android and iOS bindings own a Manager instance and inject it through the constructors; consumers hold the concrete *Manager whose nil zero value reports always-online and never sweeps, with interfaces kept only as parameter contracts. The relay guard settle wait moved into the Manager as WaitSettled, removing the netevents import from the relay package.
This commit is contained in:
@@ -21,8 +21,7 @@ import (
|
||||
"google.golang.org/grpc/connectivity"
|
||||
|
||||
nbgrpc "github.com/netbirdio/netbird/client/grpc"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/netevents"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/encryption"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -64,12 +63,9 @@ type GrpcClient struct {
|
||||
connStateCallbackLock sync.RWMutex
|
||||
serverURL string
|
||||
|
||||
// netState gates the stream retry loop on OS-reported network
|
||||
// availability; nil (the default) disables gating.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper cuts the transport connections on network change; nil disables it.
|
||||
sweeper *netsweep.Sweeper
|
||||
// netEvents gates the stream retry loop on OS-reported network
|
||||
// availability and sweeps the transport on network change.
|
||||
netEvents *netevents.Manager
|
||||
|
||||
// syncStreamErr holds the last Sync stream error, or nil while the stream
|
||||
// is established and healthy. GetServerKey succeeds even when the peer
|
||||
@@ -123,15 +119,9 @@ func MaxRecvMsgSize() int {
|
||||
// Option configures optional GrpcClient behavior.
|
||||
type Option func(*GrpcClient)
|
||||
|
||||
// WithNetworkState injects the OS network availability state that gates the
|
||||
// stream retry loop; without it gating is disabled.
|
||||
func WithNetworkState(netState *netstate.State) Option {
|
||||
return func(c *GrpcClient) { c.netState = netState }
|
||||
}
|
||||
|
||||
// WithSweeper injects the network change sweeper.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) Option {
|
||||
return func(c *GrpcClient) { c.sweeper = sweeper }
|
||||
// WithNetEvents injects the OS network event handling.
|
||||
func WithNetEvents(events *netevents.Manager) Option {
|
||||
return func(c *GrpcClient) { c.netEvents = events }
|
||||
}
|
||||
|
||||
// NewClient creates a new client to Management service
|
||||
@@ -152,9 +142,7 @@ func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsE
|
||||
extraOpts = append(extraOpts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxSize)))
|
||||
log.Infof("management gRPC max receive message size set to %d bytes", maxSize)
|
||||
}
|
||||
if c.sweeper != nil {
|
||||
extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.sweeper))
|
||||
}
|
||||
extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.netEvents))
|
||||
|
||||
var conn *grpc.ClientConn
|
||||
operation := func() error {
|
||||
@@ -235,12 +223,12 @@ func (c *GrpcClient) withMgmtStream(
|
||||
ctx context.Context,
|
||||
handler func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error,
|
||||
) error {
|
||||
backOff := c.sweeper.QuickRetryBackoff(ctx, defaultBackoff(ctx), c.netState)
|
||||
backOff := c.netEvents.QuickRetryBackoff(ctx, defaultBackoff(ctx))
|
||||
operation := func() error {
|
||||
// suspend reconnect attempts while the OS reports no usable network.
|
||||
// Wait only errors on a cancelled context, which means shutdown, so
|
||||
// stop the loop without reporting a failure.
|
||||
if waited, err := c.netState.Wait(ctx); err != nil {
|
||||
if waited, err := c.netEvents.Wait(ctx); err != nil {
|
||||
log.Debugf("management connection context has been canceled while offline, this usually indicates shutdown")
|
||||
return nil //nolint:nilerr // a cancelled context means shutdown, not a retryable failure
|
||||
} else if waited {
|
||||
@@ -273,7 +261,7 @@ func (c *GrpcClient) withMgmtStream(
|
||||
return handler(ctx, *serverPubKey, backOff)
|
||||
}
|
||||
|
||||
err := nbgrpc.Retry(ctx, operation, backOff, c.netState)
|
||||
err := nbgrpc.Retry(ctx, operation, backOff, c.netEvents)
|
||||
if err != nil {
|
||||
log.Warnf("exiting the Management service connection retry loop due to the unrecoverable error: %s", err)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/netevents/sweep"
|
||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||
"github.com/netbirdio/netbird/shared/relay/client/dialer"
|
||||
netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
|
||||
@@ -151,6 +151,14 @@ type transportConn interface {
|
||||
Protocol() string
|
||||
}
|
||||
|
||||
// NetEvents is the OS network event view the relay consumes: availability
|
||||
// gating for the reconnect guard and dial registration for the network change
|
||||
// sweep.
|
||||
type NetEvents interface {
|
||||
NetworkWatcher
|
||||
StartDial(ctx context.Context) *sweep.Dial
|
||||
}
|
||||
|
||||
// Client is a client for the relay server. It is responsible for establishing a connection to the relay server and
|
||||
// managing connections to other peers. All exported functions are safe to call concurrently. After close the connection,
|
||||
// the client can be reused by calling Connect again. When the client is closed, all connections are closed too.
|
||||
@@ -186,9 +194,10 @@ type Client struct {
|
||||
// the manager.
|
||||
transportFallback *transportFallback
|
||||
|
||||
// sweeper cuts the relay connection on network change; the read loop
|
||||
// reports the disconnect and the guard reconnects. Shared via the manager.
|
||||
sweeper *netsweep.Sweeper
|
||||
// netEvents registers the relay dial for the network change sweep; the
|
||||
// read loop reports the disconnect and the guard reconnects. Shared via
|
||||
// the manager.
|
||||
netEvents NetEvents
|
||||
// datagramFallbackTriggered guards a single fallback per connection so a
|
||||
// burst of oversized datagrams triggers one reconnect, not many.
|
||||
datagramFallbackTriggered atomic.Bool
|
||||
@@ -400,7 +409,12 @@ func (c *Client) Close() error {
|
||||
func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
|
||||
// A sweep cancels this context, so a dial started on the old network
|
||||
// aborts instead of waiting out its handshake timeout.
|
||||
dial := c.sweeper.StartDial(ctx)
|
||||
var dial *sweep.Dial
|
||||
if c.netEvents != nil {
|
||||
dial = c.netEvents.StartDial(ctx)
|
||||
} else {
|
||||
dial = (*sweep.Sweeper)(nil).StartDial(ctx)
|
||||
}
|
||||
defer dial.Release()
|
||||
ctx = dial.Ctx()
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -24,6 +22,13 @@ const (
|
||||
verdictSettleWindow = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// NetworkWatcher is the availability view the guard gates reconnects on.
|
||||
type NetworkWatcher interface {
|
||||
Wait(ctx context.Context) (bool, error)
|
||||
IsOnline() bool
|
||||
WaitSettled(ctx context.Context, budget, settleWindow time.Duration) bool
|
||||
}
|
||||
|
||||
// Guard manage the reconnection tries to the Relay server in case of disconnection event.
|
||||
type Guard struct {
|
||||
// OnNewRelayClient is a channel that is used to notify the relay manager about a new relay client instance.
|
||||
@@ -35,9 +40,8 @@ type Guard struct {
|
||||
// attempts.
|
||||
maxBackoffInterval time.Duration
|
||||
|
||||
// netState gates reconnect attempts on OS-reported network availability;
|
||||
// nil disables gating.
|
||||
netState *netstate.State
|
||||
// netState gates reconnect attempts on OS-reported network availability.
|
||||
netState NetworkWatcher
|
||||
|
||||
// lastErr is the error from the most recent failed reconnect attempt,
|
||||
// surfaced as the home relay status while disconnected.
|
||||
@@ -45,9 +49,8 @@ type Guard struct {
|
||||
}
|
||||
|
||||
// NewGuard creates a new guard for the relay client. A non-positive
|
||||
// maxBackoffInterval falls back to defaultMaxBackoffInterval. A nil netState
|
||||
// disables network availability gating.
|
||||
func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration, netState *netstate.State) *Guard {
|
||||
// maxBackoffInterval falls back to defaultMaxBackoffInterval.
|
||||
func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration, netState NetworkWatcher) *Guard {
|
||||
if maxBackoffInterval <= 0 {
|
||||
maxBackoffInterval = defaultMaxBackoffInterval
|
||||
}
|
||||
@@ -97,12 +100,14 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// suspend reconnect attempts while the OS reports no usable network
|
||||
if waited, err := g.netState.Wait(ctx); err != nil {
|
||||
return
|
||||
} else if waited {
|
||||
ticker.Stop()
|
||||
ticker = g.exponentTicker(ctx)
|
||||
continue
|
||||
if g.netState != nil {
|
||||
if waited, err := g.netState.Wait(ctx); err != nil {
|
||||
return
|
||||
} else if waited {
|
||||
ticker.Stop()
|
||||
ticker = g.exponentTicker(ctx)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := g.retry(ctx); err != nil {
|
||||
log.Errorf("failed to pick new Relay server: %s", err)
|
||||
@@ -129,13 +134,18 @@ func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool
|
||||
return false
|
||||
}
|
||||
|
||||
if ok := g.waitForNetwork(parentCtx); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Still offline after the budget: leave the retry to the ticker.
|
||||
if !g.netState.IsOnline() {
|
||||
return false
|
||||
if g.netState != nil {
|
||||
if ok := g.netState.WaitSettled(parentCtx, quickReconnectBudget, verdictSettleWindow); !ok {
|
||||
return false
|
||||
}
|
||||
// Still offline after the budget: leave the retry to the ticker.
|
||||
if !g.netState.IsOnline() {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if cancelled := waiteBeforeRetry(parentCtx); !cancelled {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("try to reconnect to Relay server: %s", rc.connectionURL)
|
||||
@@ -200,47 +210,14 @@ func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker {
|
||||
return backoff.NewTicker(bo)
|
||||
}
|
||||
|
||||
// waitForNetwork waits out the settle window while online, or waits for the
|
||||
// network to return while offline, within the budget. Returns false when ctx
|
||||
// is cancelled. Without an injected netState it degrades to a fixed
|
||||
// budget-long sleep, the pre-netstate behavior.
|
||||
func (g *Guard) waitForNetwork(ctx context.Context) bool {
|
||||
budget := time.NewTimer(quickReconnectBudget)
|
||||
defer budget.Stop()
|
||||
func waiteBeforeRetry(ctx context.Context) bool {
|
||||
timer := time.NewTimer(quickReconnectBudget)
|
||||
defer timer.Stop()
|
||||
|
||||
settleWindow := verdictSettleWindow
|
||||
if g.netState == nil {
|
||||
settleWindow = quickReconnectBudget
|
||||
}
|
||||
settle := time.NewTimer(settleWindow)
|
||||
defer settle.Stop()
|
||||
|
||||
for {
|
||||
// Channel first, flag second: a flip in between still fires the channel.
|
||||
changedCh := g.netState.Changed()
|
||||
if g.netState.IsOnline() {
|
||||
select {
|
||||
case <-settle.C:
|
||||
return true
|
||||
case <-changedCh:
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case <-budget.C:
|
||||
return true
|
||||
case <-changedCh:
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !settle.Stop() {
|
||||
select {
|
||||
case <-settle.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
settle.Reset(settleWindow)
|
||||
select {
|
||||
case <-timer.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
func TestWaitForNetworkSettlesAfterOutage(t *testing.T) {
|
||||
ns := netstate.New()
|
||||
ns.Set(false)
|
||||
g := NewGuard(nil, 0, ns)
|
||||
|
||||
const outage = 2 * verdictSettleWindow
|
||||
start := time.Now()
|
||||
go func() {
|
||||
time.Sleep(outage)
|
||||
ns.Set(true)
|
||||
}()
|
||||
|
||||
ok := g.waitForNetwork(context.Background())
|
||||
elapsed := time.Since(start)
|
||||
|
||||
assert.True(t, ok, "recovered network must let the quick reconnect proceed")
|
||||
assert.GreaterOrEqual(t, elapsed, outage+verdictSettleWindow, "reconnect must wait a full settle window after the network returns")
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
relayAuth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||
)
|
||||
|
||||
@@ -67,15 +65,9 @@ func WithMaxBackoffInterval(d time.Duration) ManagerOption {
|
||||
return func(m *Manager) { m.maxBackoffInterval = d }
|
||||
}
|
||||
|
||||
// WithNetworkState injects the OS network availability state that gates the
|
||||
// reconnect guard; without it reconnect attempts are not gated.
|
||||
func WithNetworkState(netState *netstate.State) ManagerOption {
|
||||
return func(m *Manager) { m.netState = netState }
|
||||
}
|
||||
|
||||
// WithSweeper injects the network change sweeper.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) ManagerOption {
|
||||
return func(m *Manager) { m.sweeper = sweeper }
|
||||
// WithNetEvents injects the OS network event handling.
|
||||
func WithNetEvents(events NetEvents) ManagerOption {
|
||||
return func(m *Manager) { m.netEvents = events }
|
||||
}
|
||||
|
||||
// Manager is a manager for the relay client instances. It establishes one persistent connection to the given relay URL
|
||||
@@ -105,8 +97,7 @@ type Manager struct {
|
||||
|
||||
mtu uint16
|
||||
maxBackoffInterval time.Duration
|
||||
netState *netstate.State
|
||||
sweeper *netsweep.Sweeper
|
||||
netEvents NetEvents
|
||||
|
||||
cleanupInterval time.Duration
|
||||
keepUnusedServerTime time.Duration
|
||||
@@ -143,9 +134,9 @@ func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uin
|
||||
for _, opt := range opts {
|
||||
opt(m)
|
||||
}
|
||||
m.serverPicker.Sweeper = m.sweeper
|
||||
m.serverPicker.NetEvents = m.netEvents
|
||||
m.serverPicker.ServerURLs.Store(serverURLs)
|
||||
m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval, m.netState)
|
||||
m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval, m.netEvents)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -370,7 +361,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string
|
||||
|
||||
relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu)
|
||||
relayClient.SetTransportFallback(m.transportFallback)
|
||||
relayClient.sweeper = m.sweeper
|
||||
relayClient.netEvents = m.netEvents
|
||||
err := relayClient.Connect(m.ctx)
|
||||
if err != nil {
|
||||
rt.Lock()
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||
)
|
||||
|
||||
@@ -31,7 +30,7 @@ type ServerPicker struct {
|
||||
MTU uint16
|
||||
ConnectionTimeout time.Duration
|
||||
TransportFallback *transportFallback
|
||||
Sweeper *netsweep.Sweeper
|
||||
NetEvents NetEvents
|
||||
}
|
||||
|
||||
func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
|
||||
@@ -75,7 +74,7 @@ func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan con
|
||||
log.Infof("try to connecting to relay server: %s", url)
|
||||
relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU)
|
||||
relayClient.SetTransportFallback(sp.TransportFallback)
|
||||
relayClient.sweeper = sp.Sweeper
|
||||
relayClient.netEvents = sp.NetEvents
|
||||
err := relayClient.Connect(ctx)
|
||||
resultChan <- connResult{
|
||||
RelayClient: relayClient,
|
||||
|
||||
@@ -19,8 +19,7 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
nbgrpc "github.com/netbirdio/netbird/client/grpc"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/netevents"
|
||||
"github.com/netbirdio/netbird/encryption"
|
||||
"github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/signal/proto"
|
||||
@@ -67,12 +66,9 @@ type GrpcClient struct {
|
||||
connStateCallback ConnStateNotifier
|
||||
connStateCallbackLock sync.RWMutex
|
||||
|
||||
// netState gates the Receive retry loop on OS-reported network
|
||||
// availability; nil (the default) disables gating.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper cuts the transport connections on network change; nil disables it.
|
||||
sweeper *netsweep.Sweeper
|
||||
// netEvents gates the Receive retry loop on OS-reported network
|
||||
// availability and sweeps the transport on network change.
|
||||
netEvents *netevents.Manager
|
||||
|
||||
onReconnectedListenerFn func()
|
||||
|
||||
@@ -100,15 +96,9 @@ type GrpcClient struct {
|
||||
// Option configures optional GrpcClient behavior.
|
||||
type Option func(*GrpcClient)
|
||||
|
||||
// WithNetworkState injects the OS network availability state that gates the
|
||||
// Receive retry loop; without it gating is disabled.
|
||||
func WithNetworkState(netState *netstate.State) Option {
|
||||
return func(c *GrpcClient) { c.netState = netState }
|
||||
}
|
||||
|
||||
// WithSweeper injects the network change sweeper.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) Option {
|
||||
return func(c *GrpcClient) { c.sweeper = sweeper }
|
||||
// WithNetEvents injects the OS network event handling.
|
||||
func WithNetEvents(events *netevents.Manager) Option {
|
||||
return func(c *GrpcClient) { c.netEvents = events }
|
||||
}
|
||||
|
||||
// NewClient creates a new Signal client
|
||||
@@ -126,9 +116,7 @@ func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled boo
|
||||
}
|
||||
|
||||
var extraOpts []grpc.DialOption
|
||||
if c.sweeper != nil {
|
||||
extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.sweeper))
|
||||
}
|
||||
extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.netEvents))
|
||||
|
||||
var conn *grpc.ClientConn
|
||||
operation := func() error {
|
||||
@@ -198,13 +186,13 @@ func defaultBackoff(ctx context.Context) backoff.BackOff {
|
||||
// The connection retry logic will try to reconnect for 30 min and if wasn't successful will propagate the error to the function caller.
|
||||
func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Message) error) error {
|
||||
|
||||
backOff := c.sweeper.QuickRetryBackoff(ctx, defaultBackoff(ctx), c.netState)
|
||||
backOff := c.netEvents.QuickRetryBackoff(ctx, defaultBackoff(ctx))
|
||||
|
||||
operation := func() error {
|
||||
// suspend reconnect attempts while the OS reports no usable network.
|
||||
// Wait only errors on a cancelled context, which means shutdown, so
|
||||
// stop the loop without reporting a failure.
|
||||
if waited, err := c.netState.Wait(ctx); err != nil {
|
||||
if waited, err := c.netEvents.Wait(ctx); err != nil {
|
||||
log.Debugf("signal connection context has been canceled while offline, this usually indicates shutdown")
|
||||
return nil
|
||||
} else if waited {
|
||||
@@ -281,7 +269,7 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes
|
||||
return nil
|
||||
}
|
||||
|
||||
err := nbgrpc.Retry(ctx, operation, backOff, c.netState)
|
||||
err := nbgrpc.Retry(ctx, operation, backOff, c.netEvents)
|
||||
if err != nil {
|
||||
log.Errorf("exiting the Signal service connection retry loop due to the unrecoverable error: %v", err)
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user