From fde2181c3bc767437f0b44101750e3012d097637 Mon Sep 17 00:00:00 2001 From: Giacomo Di Clerico <84620724+MishowHD@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:26:05 +0200 Subject: [PATCH] tailscale: handle profile switches and bus updates reactively (#3427) * tailscale: handle profile switches and bus updates reactively * tailscale: address review feedback on test coverage and dead code (cherry picked from commit 218f7f752f87a73989fd188abd548c4813a3336f) --- core/internal/server/tailscale/client.go | 1 + core/internal/server/tailscale/manager.go | 12 +++-- .../internal/server/tailscale/manager_test.go | 52 +++++++++++++++++++ .../BuiltinPlugins/TailscaleWidget.qml | 4 ++ quickshell/Services/TailscaleService.qml | 4 ++ 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/core/internal/server/tailscale/client.go b/core/internal/server/tailscale/client.go index 987b927e8..0602fc067 100644 --- a/core/internal/server/tailscale/client.go +++ b/core/internal/server/tailscale/client.go @@ -33,6 +33,7 @@ func convertStatus(status *ipnstate.Status) *TailscaleState { if status.Self != nil { state.Self = convertPeerStatus(status.Self, users) + state.Self.Online = true } if len(status.Peer) > 0 { diff --git a/core/internal/server/tailscale/manager.go b/core/internal/server/tailscale/manager.go index a30496fe7..24270c720 100644 --- a/core/internal/server/tailscale/manager.go +++ b/core/internal/server/tailscale/manager.go @@ -95,7 +95,7 @@ func newManager(client tailscaleClient) *Manager { func (m *Manager) watchLoop(ctx context.Context) { defer m.watchWG.Done() - mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit + mask := ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit backoff := time.Second unreachableSent := false @@ -122,8 +122,13 @@ func (m *Manager) watchLoop(ctx context.Context) { log.Info("[Tailscale] Connected to IPN bus") m.markAvailable() + select { + case m.dirty <- struct{}{}: + default: + } + for { - notify, err := watcher.Next() + _, err := watcher.Next() if err != nil { log.Warnf("[Tailscale] IPN bus error: %v", err) break @@ -131,9 +136,6 @@ func (m *Manager) watchLoop(ctx context.Context) { backoff = time.Second - if notify.State == nil && notify.NetMap == nil { //nolint:staticcheck // NetMap is deprecated upstream but still the only activity signal on some platforms - continue - } select { case m.dirty <- struct{}{}: default: diff --git a/core/internal/server/tailscale/manager_test.go b/core/internal/server/tailscale/manager_test.go index afe980b6f..41be8402e 100644 --- a/core/internal/server/tailscale/manager_test.go +++ b/core/internal/server/tailscale/manager_test.go @@ -418,3 +418,55 @@ func TestManager_Actions_PropagateError(t *testing.T) { assert.Error(t, m.SetExitNode("nABC123")) assert.Error(t, m.SetAllowLANAccess(true)) } + +type dynamicWatcher struct { + ctx context.Context + ch chan ipn.Notify +} + +func (w *dynamicWatcher) Next() (ipn.Notify, error) { + select { + case n, ok := <-w.ch: + if !ok { + return ipn.Notify{}, fmt.Errorf("watcher closed") + } + return n, nil + case <-w.ctx.Done(): + return ipn.Notify{}, w.ctx.Err() + } +} + +func (w *dynamicWatcher) Close() error { + return nil +} + +func TestWatchLoop_NotifyWithoutStateOrNetMap(t *testing.T) { + var statusCalls atomic.Int32 + notifyCh := make(chan ipn.Notify, 1) + + client := &mockClient{ + watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { + return &dynamicWatcher{ctx: ctx, ch: notifyCh}, nil + }, + statusFn: func(ctx context.Context) (*ipnstate.Status, error) { + statusCalls.Add(1) + return runningStatus(), nil + }, + } + + m := newManager(client) + defer m.Close() + + // Wait for the initial connect-time status call to complete. + require.Eventually(t, func() bool { + return statusCalls.Load() == 1 + }, 2*time.Second, 10*time.Millisecond) + + // Send an event with State == nil and NetMap == nil. + notifyCh <- ipn.Notify{Engine: &ipn.EngineStatus{}} + + // Verify that the notification without State or NetMap triggers a second status call. + require.Eventually(t, func() bool { + return statusCalls.Load() >= 2 + }, 2*time.Second, 10*time.Millisecond) +} diff --git a/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml b/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml index d2d2a2876..7582678a4 100644 --- a/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml +++ b/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml @@ -34,6 +34,10 @@ PluginComponent { TailscaleService.connectTailscale(null); } + onCcWidgetExpanded: { + TailscaleService.refresh(null); + } + ccDetailContent: Component { Rectangle { id: detailRoot diff --git a/quickshell/Services/TailscaleService.qml b/quickshell/Services/TailscaleService.qml index 689330180..508f9df00 100644 --- a/quickshell/Services/TailscaleService.qml +++ b/quickshell/Services/TailscaleService.qml @@ -207,6 +207,10 @@ Singleton { } function isMine(peer) { + if (!peer) + return false; + if (selfNode && (peer === selfNode || peer.id === selfNode.id)) + return true; const myOwner = selfNode ? (selfNode.owner || "") : ""; if (peer.owner === myOwner && myOwner !== "") return true;