Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions net/netmon/netmon.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ import (
// us check the wall time sooner than this.
const pollWallTimeInterval = 15 * time.Second

// postWakePollDuration is how long to keep polling interfaces after a time
// jump. The first wake snapshot can run before Wi-Fi or DHCP has recovered,
// and the OS does not always deliver another address or route notification.
const postWakePollDuration = 10 * time.Minute

const postWakePollCount = int(postWakePollDuration / pollWallTimeInterval)

// majorTimeJumpThreshold is the minimum sleep duration that warrants
// treating a time jump as a major event requiring socket rebinding,
// even if the interface state appears unchanged. After a long sleep,
Expand Down Expand Up @@ -77,18 +84,19 @@ type Monitor struct {
stop chan struct{} // closed on Stop
static bool // static Monitor that doesn't actually monitor

mu syncs.Mutex // guards all following fields
cbs set.HandleSet[ChangeFunc]
ifState *State
gwValid bool // whether gw and gwSelfIP are valid
gw netip.Addr // our gateway's IP
gwSelfIP netip.Addr // our own IP address (that corresponds to gw)
started bool
closed bool
goroutines sync.WaitGroup
wallTimer *time.Timer // nil until Started; re-armed AfterFunc per tick
lastWall time.Time
jumpDuration time.Duration // wall-clock time elapsed during detected time jump; 0 if no time jump observed since reset
mu syncs.Mutex // guards all following fields
cbs set.HandleSet[ChangeFunc]
ifState *State
gwValid bool // whether gw and gwSelfIP are valid
gw netip.Addr // our gateway's IP
gwSelfIP netip.Addr // our own IP address (that corresponds to gw)
started bool
closed bool
goroutines sync.WaitGroup
wallTimer *time.Timer // nil until Started; re-armed AfterFunc per tick
lastWall time.Time
jumpDuration time.Duration // wall-clock time elapsed during detected time jump; 0 if no time jump observed since reset
postWakePolls int // additional interface polls after a detected wake
}

// ChangeFunc is a callback function registered with Monitor that's called when the
Expand Down Expand Up @@ -702,7 +710,11 @@ func (m *Monitor) pollWallTime() {
return
}
if m.checkWallTimeAdvanceLocked() {
m.postWakePolls = postWakePollCount
m.InjectEvent()
} else if m.postWakePolls > 0 {
m.postWakePolls--
m.Poll()
}
m.wallTimer.Reset(pollWallTimeInterval)
}
Expand Down
127 changes: 127 additions & 0 deletions net/netmon/wake_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

package netmon

import (
"net"
"sync/atomic"
"testing"
"time"

"github.com/metacubex/tailscale/util/eventbus"
)

func TestPollWallTimeRetriesAfterWake(t *testing.T) {
m := &Monitor{
change: make(chan bool, 1),
lastWall: wallTime().Add(-time.Hour),
wallTimer: time.NewTimer(time.Hour),
}
defer m.wallTimer.Stop()

m.pollWallTime()
select {
case forceCallbacks := <-m.change:
if !forceCallbacks {
t.Fatal("initial wake event did not force callbacks")
}
default:
t.Fatal("time jump did not inject a wake event")
}

// Simulate debounce consuming the wake event while the physical interface
// is still down. Windows sometimes does not deliver another interface event
// after Wi-Fi finishes reconnecting.
m.mu.Lock()
m.resetTimeJumpedLocked()
m.lastWall = wallTime()
m.mu.Unlock()

for retry := 0; retry < postWakePollCount; retry++ {
m.pollWallTime()
select {
case forceCallbacks := <-m.change:
if forceCallbacks {
t.Fatalf("post-wake retry %d unexpectedly forced callbacks", retry+1)
}
default:
t.Fatalf("post-wake interface poll %d was not scheduled", retry+1)
}
}

m.pollWallTime()
select {
case <-m.change:
t.Fatalf("post-wake polling continued past %v", postWakePollDuration)
default:
}
}

func TestWakeRetryObservesRecoveredInterfaceWithoutOSEvent(t *testing.T) {
var interfaceUp atomic.Bool
interfaceUp.Store(true)

ipnet := func(s string) net.Addr {
ip, prefix, err := net.ParseCIDR(s)
if err != nil {
t.Fatal(err)
}
prefix.IP = ip
return prefix
}
oldInterfaceGetter := altNetInterfaces
altNetInterfaces = func() ([]Interface, error) {
flags := net.FlagBroadcast | net.FlagMulticast
var addrs []net.Addr
if interfaceUp.Load() {
flags |= net.FlagUp | net.FlagRunning
addrs = []net.Addr{ipnet("192.0.2.10/24")}
}
return []Interface{{
Interface: &net.Interface{Index: 100, MTU: 1500, Name: "wake-test0", Flags: flags},
AltAddrs: addrs,
}}, nil
}
t.Cleanup(func() { altNetInterfaces = oldInterfaceGetter })

bus := eventbus.New()
defer bus.Close()
m, err := New(bus, t.Logf)
if err != nil {
t.Fatal(err)
}
defer m.Close()

states := make(chan bool, 2)
m.RegisterChangeCallback(func(delta *ChangeDelta) {
states <- delta.AnyInterfaceUp()
})
m.Start()

interfaceUp.Store(false)
m.mu.Lock()
m.lastWall = wallTime().Add(-time.Hour)
m.mu.Unlock()
m.pollWallTime()
select {
case up := <-states:
if up {
t.Fatal("wake snapshot unexpectedly reported an interface up")
}
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for offline wake snapshot")
}

// The interface recovers without a Windows address or route event.
interfaceUp.Store(true)
m.pollWallTime()
select {
case up := <-states:
if !up {
t.Fatal("post-wake poll did not observe the recovered interface")
}
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for post-wake interface recovery")
}
}