[client] Suspend reconnection loops while the OS reports no network

On mobile the client kept dialing management, signal, relay and peer
connections while the device had no usable network at all (airplane
mode), burning battery for attempts that cannot succeed. Stopping the
engine is not an option: tearing it down destroys the TUN device, and
traffic can leak outside the tunnel until it is rebuilt.

Add client/netstate, a small gate the platform feeds from its own
connectivity callbacks. Every reconnection loop waits on it instead of
retrying blindly, and resets its backoff when the network returns so
recovery is immediate. The state is injected through functional options
and consumers hold a *State that may be nil, so every platform that does
not report availability behaves exactly as before.

The relay quick-reconnect rechecks availability after its 1.5s wait: the
disconnect that triggers it is usually the first symptom of the network
going away, so the flag typically arrives while it sleeps.

Report the suspension to the UI as well. peer.Listener grows
OnStateChanged with a typed ClientState, re-exported across the gomobile
boundary as integer constants, and the notifier maps Connecting to a new
NoNetwork state while the OS reports no network, so mobile clients can
show "no network available" instead of a misleading "connecting".

Finally, exit the client retry loop cleanly when its context is
cancelled. backoff.WithContext surfaces the bare context error, which
callers could not distinguish from a real failure — on Android that
turned an engine restart into an unrecoverable error.
This commit is contained in:
Zoltan Papp
2026-08-11 00:58:50 +02:00
parent 2ce6323602
commit 902263ac96
19 changed files with 673 additions and 81 deletions
+19 -5
View File
@@ -6,6 +6,8 @@ import (
"github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/netstate"
)
// ConnStatus represents the connection state as seen by the guard.
@@ -31,20 +33,26 @@ type connStatusFunc func() ConnStatus
// - Relayed connection disconnected
// - ICE candidate changes
type Guard struct {
log *log.Entry
isConnectedOnAllWay connStatusFunc
timeout time.Duration
srWatcher *SRWatcher
log *log.Entry
isConnectedOnAllWay connStatusFunc
timeout time.Duration
srWatcher *SRWatcher
// netState gates reconnect attempts on OS-reported network availability;
// nil disables gating.
netState *netstate.State
relayedConnDisconnected chan struct{}
iCEConnDisconnected chan struct{}
}
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard {
// NewGuard creates a reconnection guard for a peer connection. A nil netState
// disables network availability gating.
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netState *netstate.State) *Guard {
return &Guard{
log: log,
isConnectedOnAllWay: isConnectedFn,
timeout: timeout,
srWatcher: srWatcher,
netState: netState,
relayedConnDisconnected: make(chan struct{}, 1),
iCEConnDisconnected: make(chan struct{}, 1),
}
@@ -99,6 +107,12 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
for {
select {
case <-tickerChannel:
// skip attempts while the OS reports no usable network; the ticker
// keeps running so other events remain responsive, and the guard
// resumes via the signal/relay reconnect events once network returns
if !g.netState.IsOnline() {
continue
}
switch g.isConnectedOnAllWay() {
case ConnStatusConnected:
// all good, nothing to do
@@ -15,7 +15,7 @@ import (
func newTestGuard(status connStatusFunc) *Guard {
srw := NewSRWatcher(nil, nil, nil, ice.Config{})
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw)
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw, nil)
}
// countBackoffTickerGoroutines returns how many goroutines are currently sitting