From 3b88efd53b25981f8f5ef1a927a9b153212300c2 Mon Sep 17 00:00:00 2001 From: evilhero <2278596667@qq.com> Date: Sun, 19 Jul 2026 02:38:45 +0800 Subject: [PATCH] net/netmon: poll interfaces after wake The first interface snapshot after a device wakes can run before Wi-Fi or DHCP has recovered. Some operating systems then fail to deliver a later address or route notification, leaving consumers with a stale offline state. After a detected time jump, poll interfaces every 15 seconds for 10 minutes. These non-forcing polls only notify callbacks when the interface state changes. Updates tailscale/tailscale#10688 RELNOTE: Fixed devices sometimes remaining offline after waking from sleep. Change-Id: Idbf9126f2aeb47db89c65dbcae5a27801834c7ec Signed-off-by: evilhero <2278596667@qq.com> --- net/netmon/netmon.go | 36 ++++++---- net/netmon/wake_retry_test.go | 127 ++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 net/netmon/wake_retry_test.go diff --git a/net/netmon/netmon.go b/net/netmon/netmon.go index 24478ac38f864..755e5258aa488 100644 --- a/net/netmon/netmon.go +++ b/net/netmon/netmon.go @@ -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, @@ -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 @@ -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) } diff --git a/net/netmon/wake_retry_test.go b/net/netmon/wake_retry_test.go new file mode 100644 index 0000000000000..f4f9121e1173b --- /dev/null +++ b/net/netmon/wake_retry_test.go @@ -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") + } +}