Files
netbird/shared/relay/client/guard.go
T
Zoltán Papp 0a03211903 [client] Name the netevents fields after their types
Review feedback on #7254: the fields kept their netstate-era names after the
type changed to *netevents.Manager or a NetworkWatcher interface.

Rename the Manager-typed fields to netMgr/NetMgr and the interface-typed guard
fields to netWatcher, document that the Manager write methods panic on a nil
receiver while the read methods stay nil-safe, and fix the waiteBeforeRetry
typo.

The relay client keeps its netEvents fields: those hold the NetEvents
interface, so the name already matches the type.
2026-08-25 15:09:00 +02:00

224 lines
5.9 KiB
Go

package client
import (
"context"
"sync/atomic"
"time"
"github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus"
)
const (
defaultMaxBackoffInterval = 60 * time.Second
// quickReconnectBudget bounds how long a quick reconnect waits for the
// network before handing the retry over to the ticker.
quickReconnectBudget = 1500 * time.Millisecond
// verdictSettleWindow is how long an online verdict must hold before it
// is trusted: the disconnect often precedes the OS offline flag by a few
// milliseconds.
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.
OnNewRelayClient chan *Client
OnReconnected chan struct{}
serverPicker *ServerPicker
// maxBackoffInterval caps the exponential backoff between reconnect
// attempts.
maxBackoffInterval time.Duration
// netWatcher gates reconnect attempts on OS-reported network availability.
netWatcher NetworkWatcher
// lastErr is the error from the most recent failed reconnect attempt,
// surfaced as the home relay status while disconnected.
lastErr atomic.Pointer[error]
}
// NewGuard creates a new guard for the relay client. A non-positive
// maxBackoffInterval falls back to defaultMaxBackoffInterval.
func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration, netWatcher NetworkWatcher) *Guard {
if maxBackoffInterval <= 0 {
maxBackoffInterval = defaultMaxBackoffInterval
}
g := &Guard{
OnNewRelayClient: make(chan *Client, 1),
OnReconnected: make(chan struct{}, 1),
serverPicker: sp,
maxBackoffInterval: maxBackoffInterval,
netWatcher: netWatcher,
}
return g
}
// LastError returns the error from the most recent failed reconnect attempt, or
// nil if reconnection last succeeded.
func (g *Guard) LastError() error {
if p := g.lastErr.Load(); p != nil {
return *p
}
return nil
}
// StartReconnectTrys is called when the relay client is disconnected from the relay server.
// It attempts to reconnect to the relay server. The function first tries a quick reconnect
// to the same server that was used before, if the server URL is still valid. If the quick
// reconnect fails, it starts a ticker to periodically attempt server picking until it
// succeeds or the context is done.
//
// Parameters:
// - ctx: The context to control the lifecycle of the reconnection attempts.
// - relayClient: The relay client instance that was disconnected.
// todo prevent multiple reconnection instances. In the current usage it should not happen, but it is better to prevent
func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) {
// try to reconnect to the same server
if ok := g.tryToQuickReconnect(ctx, relayClient); ok {
g.notifyReconnected()
return
}
// start a ticker to pick a new server
ticker := g.exponentTicker(ctx)
defer func() {
ticker.Stop()
}()
for {
select {
case <-ticker.C:
// suspend reconnect attempts while the OS reports no usable network
if g.netWatcher != nil {
if waited, err := g.netWatcher.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)
g.setLastError(err)
continue
}
return
case <-ctx.Done():
return
}
}
}
func (g *Guard) setLastError(err error) {
g.lastErr.Store(&err)
}
func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool {
if rc == nil {
return false
}
if !g.isServerURLStillValid(rc) {
return false
}
if g.netWatcher != nil {
if ok := g.netWatcher.WaitSettled(parentCtx, quickReconnectBudget, verdictSettleWindow); !ok {
return false
}
// Still offline after the budget: leave the retry to the ticker.
if !g.netWatcher.IsOnline() {
return false
}
} else {
if cancelled := waitBeforeRetry(parentCtx); !cancelled {
return false
}
}
log.Infof("try to reconnect to Relay server: %s", rc.connectionURL)
if err := rc.Connect(parentCtx); err != nil {
log.Errorf("failed to reconnect to relay server: %s", err)
g.setLastError(err)
return false
}
return true
}
func (g *Guard) retry(ctx context.Context) error {
log.Infof("try to pick up a new Relay server")
relayClient, err := g.serverPicker.PickServer(ctx)
if err != nil {
return err
}
g.setLastError(nil)
// prevent to work with a deprecated Relay client instance
g.drainRelayClientChan()
g.OnNewRelayClient <- relayClient
return nil
}
func (g *Guard) drainRelayClientChan() {
select {
case <-g.OnNewRelayClient:
default:
}
}
func (g *Guard) isServerURLStillValid(rc *Client) bool {
for _, url := range g.serverPicker.ServerURLs.Load().([]string) {
if url == rc.connectionURL {
return true
}
}
return false
}
func (g *Guard) notifyReconnected() {
g.setLastError(nil)
select {
case g.OnReconnected <- struct{}{}:
default:
}
}
func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker {
bo := backoff.WithContext(&backoff.ExponentialBackOff{
InitialInterval: 2 * time.Second,
// Spreads the reconnects of every client that lost the same relay server.
RandomizationFactor: backoff.DefaultRandomizationFactor,
Multiplier: 2,
MaxInterval: g.maxBackoffInterval,
Clock: backoff.SystemClock,
}, ctx)
return backoff.NewTicker(bo)
}
func waitBeforeRetry(ctx context.Context) bool {
timer := time.NewTimer(quickReconnectBudget)
defer timer.Stop()
select {
case <-timer.C:
return true
case <-ctx.Done():
return false
}
}