Compare commits

..

2 Commits

Author SHA1 Message Date
Zoltán Papp
4c95b20251 [client] Drain buffered tick when coalesce window expires 2026-07-28 16:30:15 +02:00
Zoltán Papp
e1d82fc326 [client] Coalesce SubscribeStatus snapshot bursts 2026-07-28 16:13:32 +02:00
8 changed files with 85 additions and 150 deletions

View File

@@ -1,20 +1,27 @@
package server
import (
"context"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
const statusCoalesceWindow = 200 * time.Millisecond
// SubscribeStatus pushes a fresh StatusResponse on every connection state
// change. The first message is the current snapshot, so a re-subscribing
// client doesn't need to also call Status. Subsequent messages fire when
// the peer recorder reports any of: connected/disconnected/connecting,
// management or signal flip, address change, or peers list change.
//
// The change channel coalesces bursts to a single tick. If the consumer
// is slow the daemon drops extras (not blocks), and the next snapshot
// the consumer pulls already reflects everything.
// Bursts are coalesced deterministically: the first tick after a quiet
// period is sent immediately, then a short window swallows the rest of the
// burst and a single trailing snapshot covers whatever arrived meanwhile.
// Every send is a full snapshot of the recorder's current state, so
// swallowed ticks lose no information.
func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
subID, ch := s.statusRecorder.SubscribeToStateChanges()
defer func() {
@@ -37,12 +44,50 @@ func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonSe
if err := s.sendStatusSnapshot(req, stream); err != nil {
return err
}
pending, open := collectStatusBurst(stream.Context(), ch)
if pending {
if err := s.sendStatusSnapshot(req, stream); err != nil {
return err
}
}
if !open {
return nil
}
case <-stream.Context().Done():
return nil
}
}
}
// collectStatusBurst waits out the coalesce window, absorbing further ticks.
// pending reports whether any tick arrived; open is false when the channel
// closed or the stream context ended.
func collectStatusBurst(ctx context.Context, ch <-chan struct{}) (pending, open bool) {
timer := time.NewTimer(statusCoalesceWindow)
defer timer.Stop()
for {
select {
case _, ok := <-ch:
if !ok {
return pending, false
}
pending = true
case <-timer.C:
select {
case _, ok := <-ch:
if !ok {
return pending, false
}
pending = true
default:
}
return pending, true
case <-ctx.Done():
return false, false
}
}
}
func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
resp, err := s.buildStatusResponse(stream.Context(), req)
if err != nil {

View File

@@ -103,18 +103,6 @@ func main() {
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
// Status snapshots go only to visible windows — a snapshot is full state,
// so a hidden webview loses nothing by being skipped; it gets the cached
// one on show (SetShowReplay below). Every other event stays on the bus.
daemonFeed.SetWindowDispatcher(func(st services.Status) {
ev := &application.CustomEvent{Name: services.EventStatusSnapshot, Data: st}
for _, w := range app.Window.GetAll() {
if w == nil || !w.IsVisible() {
continue
}
w.DispatchWailsEvent(ev)
}
})
notifier := notifications.New()
compat := services.NewCompat(conn)
// macOS shows no toast until permission is requested. Run it after
@@ -164,31 +152,8 @@ func main() {
// re-centering on that environment; nil leaves placement to the WM on full
// desktops, macOS, and Windows.
windowManager.SetRecenterOnShow(recenterOnShowPredicate())
// Replay the latest snapshot into a window on (re)show, so a webview that
// was hidden while pushes flowed never paints stale state. ReplayLast keeps
// the feed's status lock across the dispatch, so a racing live push can't
// slip in between and then be overwritten by this older cached snapshot.
windowManager.SetShowReplay(func(w application.Window) {
daemonFeed.ReplayLast(func(st services.Status) {
w.DispatchWailsEvent(&application.CustomEvent{Name: services.EventStatusSnapshot, Data: st})
})
})
app.RegisterService(application.NewService(windowManager))
// On macOS, Wails' default applicationShouldHandleReopen handler Show()s
// every hidden window on dock-icon click, resurrecting hide-on-close
// surfaces like Settings. Cancel it in a hook (hooks run before listeners)
// and show only the main window. No-op elsewhere — the event never fires.
if runtime.GOOS == "darwin" {
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
e.Cancel()
if e.Context().HasVisibleWindows() {
return
}
windowManager.ShowMain()
})
}
// Welcome window, first launch only — Continue flips OnboardingCompleted
// so later launches skip it. ApplicationStarted hook so the Wails window
// machinery is fully up before the window is created.
@@ -412,5 +377,20 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
window.Hide()
})
// On macOS, Wails' default applicationShouldHandleReopen handler Show()s
// every hidden window on dock-icon click, resurrecting hide-on-close
// surfaces like Settings. Cancel it in a hook (hooks run before listeners)
// and show only the main window. No-op elsewhere — the event never fires.
if runtime.GOOS == "darwin" {
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
e.Cancel()
if e.Context().HasVisibleWindows() {
return
}
window.Show()
window.Focus()
})
}
return window
}

View File

@@ -5,7 +5,6 @@ package services
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
@@ -168,14 +167,6 @@ type DaemonFeed struct {
cancel context.CancelFunc
streamWg sync.WaitGroup
// statusSubs are Go-side snapshot consumers (the tray), fed directly so
// they don't ride the window event bus. Callbacks run synchronously on
// the stream goroutine, so pushes arrive in order.
statusSubsMu sync.Mutex
statusSubs []func(Status)
lastStatus *Status
windowDispatcher func(Status)
switchMu sync.Mutex
switchInProgress bool
switchInProgressUntil time.Time
@@ -197,55 +188,6 @@ func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Hold
return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder, logCtl: logCtl}
}
// OnStatus registers a Go-side status subscriber. Not for the frontend —
// React consumers subscribe to EventStatusSnapshot on the event bus.
func (s *DaemonFeed) OnStatus(cb func(Status)) {
s.statusSubsMu.Lock()
s.statusSubs = append(s.statusSubs, cb)
s.statusSubsMu.Unlock()
}
// SetWindowDispatcher installs the frontend push path: it receives every
// snapshot and decides which webview windows get it (visible ones). While
// unset, pushStatus falls back to the event-bus broadcast.
func (s *DaemonFeed) SetWindowDispatcher(fn func(Status)) {
s.statusSubsMu.Lock()
s.windowDispatcher = fn
s.statusSubsMu.Unlock()
}
// pushStatus delivers a snapshot to the Go-side subscribers and the frontend,
// and caches it for ReplayLast. Hidden windows are skipped by the
// window dispatcher; they catch up via the show replay (WindowManager).
func (s *DaemonFeed) pushStatus(st Status) {
s.statusSubsMu.Lock()
s.lastStatus = &st
subs := slices.Clone(s.statusSubs)
dispatch := s.windowDispatcher
s.statusSubsMu.Unlock()
for _, cb := range subs {
cb(st)
}
if dispatch != nil {
dispatch(st)
return
}
s.emitter.Emit(EventStatusSnapshot, st)
}
// ReplayLast feeds the most recently pushed snapshot to dispatch; no-op before
// the first push. The status lock is held across the dispatch so a concurrent
// pushStatus cannot deliver a newer snapshot in between the read and the
// dispatch — the replayed value is never older than anything already delivered.
func (s *DaemonFeed) ReplayLast(dispatch func(Status)) {
s.statusSubsMu.Lock()
defer s.statusSubsMu.Unlock()
if s.lastStatus == nil {
return
}
dispatch(*s.lastStatus)
}
// BeginProfileSwitch arms suppression for a switch from Connected/Connecting,
// where the daemon emits stale Connected updates during Down's teardown then an
// Idle before the new Up; statusStreamLoop drops those, and a synthetic
@@ -259,7 +201,7 @@ func (s *DaemonFeed) BeginProfileSwitch() {
s.switchLoginWatch = true
s.switchLoginWatchUntil = now.Add(30 * time.Second)
s.switchMu.Unlock()
s.pushStatus(Status{Status: StatusConnecting})
s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusConnecting})
}
// CancelProfileSwitch aborts a switch midway (tray Disconnect while Connecting):
@@ -401,7 +343,7 @@ func (s *DaemonFeed) statusStreamLoop(ctx context.Context) {
return
}
unavailable = true
s.pushStatus(Status{Status: StatusDaemonUnavailable})
s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusDaemonUnavailable})
}
op := func() error {
@@ -461,7 +403,7 @@ func (s *DaemonFeed) emitStatus(st Status) {
log.Debugf("suppressing status=%q during profile switch", st.Status)
return
}
s.pushStatus(st)
s.emitter.Emit(EventStatusSnapshot, st)
if triggerLogin {
s.emitter.Emit(EventTriggerLogin)
}

View File

@@ -115,9 +115,6 @@ type WindowManager struct {
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
// restores position; nil on full desktops so re-centering can't fight a user-moved window.
recenterOnShow func() bool
// showReplay fires whenever a live-but-hidden window is (re)shown, so the
// caller can replay the latest status snapshot into its webview.
showReplay func(application.Window)
}
// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The
@@ -177,7 +174,6 @@ func (s *WindowManager) OpenSettings(tab string) {
s.app.Event.Emit(EventSettingsOpen, target)
s.settings.Show()
s.settings.Focus()
s.notifyShown(s.settings)
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.settings)
}
@@ -224,7 +220,6 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
s.centerOnCursorScreen(s.browserLogin)
s.browserLogin.Show()
s.browserLogin.Focus()
s.notifyShown(s.browserLogin)
}
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
@@ -285,7 +280,6 @@ func (s *WindowManager) OpenSessionExpiration(seconds int) {
s.centerOnCursorScreen(s.sessionExpiration)
s.sessionExpiration.Show()
s.sessionExpiration.Focus()
s.notifyShown(s.sessionExpiration)
}
func (s *WindowManager) CloseSessionExpiration() {
@@ -353,7 +347,6 @@ func (s *WindowManager) OpenInstallProgress(version string) {
s.installProgress.SetURL(startURL)
s.installProgress.Show()
s.installProgress.Focus()
s.notifyShown(s.installProgress)
s.centerWhenReady(s.installProgress)
}
@@ -387,7 +380,6 @@ func (s *WindowManager) OpenWelcome() {
}
s.welcome.Show()
s.welcome.Focus()
s.notifyShown(s.welcome)
s.centerWhenReady(s.welcome)
}
@@ -425,7 +417,6 @@ func (s *WindowManager) OpenError(title, message string) {
s.errorDialog.SetURL(startURL)
s.errorDialog.Show()
s.errorDialog.Focus()
s.notifyShown(s.errorDialog)
s.centerWhenReady(s.errorDialog)
}
@@ -452,7 +443,6 @@ func (s *WindowManager) ShowMain() {
}
s.mainWindow.Show()
s.mainWindow.Focus()
s.notifyShown(s.mainWindow)
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.mainWindow)
}
@@ -462,17 +452,6 @@ func (s *WindowManager) SetRecenterOnShow(pred func() bool) {
s.recenterOnShow = pred
}
// SetShowReplay installs the shown-window hook (see the showReplay field).
func (s *WindowManager) SetShowReplay(fn func(application.Window)) {
s.showReplay = fn
}
func (s *WindowManager) notifyShown(w application.Window) {
if s.showReplay != nil && w != nil {
s.showReplay(w)
}
}
// centerWhenReady centers w only on minimal WMs (recenterOnShow); elsewhere it
// returns so it never fights a user-moved window. On GTK4 an inline Center()
// no-ops until the GdkSurface is realized (async, after Show) and InvokeAsync
@@ -593,7 +572,6 @@ func (s *WindowManager) restoreHiddenWindowsLocked() {
continue
}
w.Show()
s.notifyShown(w)
if w == s.mainWindow {
mainRestored = true
}

View File

@@ -172,7 +172,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// in the right locale — no English flash then re-paint.
loc: svc.Localizer,
}
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() }, func() { t.showMainWindow() })
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
t.tray = app.SystemTray.New()
// Seed panel-theme detection before the first paint so the initial icon
// matches the panel's light/dark scheme (Linux only).
@@ -196,7 +196,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// menu (e.g. GNOME Shell AppIndicator).
bindTrayClick(t)
svc.DaemonFeed.OnStatus(t.applyStatus)
app.Event.On(services.EventStatusSnapshot, t.onStatusEvent)
app.Event.On(services.EventDaemonNotification, t.onSystemEvent)
// Refresh the Profiles submenu on ProfileSwitcher's change event. A
// switch on an idle daemon drives no status transition, so without this
@@ -253,19 +253,6 @@ func (t *Tray) ShowWindow() {
t.window.Focus()
}
// showMainWindow brings the main window forward through the WindowManager so
// the status replay and re-centering apply; falls back to a bare Show in tests.
func (t *Tray) showMainWindow() {
if t.svc.WindowManager != nil {
t.svc.WindowManager.ShowMain()
return
}
if t.window != nil {
t.window.Show()
t.window.Focus()
}
}
// applyLanguage re-renders every translated surface in the Localizer's current
// language. Wails dispatches menu/tray APIs onto the UI thread internally, so
// calling them from the Localizer's background goroutine is safe; profileLoadMu

View File

@@ -30,11 +30,11 @@ const (
// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal.
func (t *Tray) handleSessionExpired() {
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
if t.window == nil {
return
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
}
t.window.SetURL("/#/login")
t.showMainWindow()
}
// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed.
@@ -310,7 +310,8 @@ func (t *Tray) openSessionExtendFlow() {
if seconds <= 0 {
if t.window != nil {
t.window.SetURL("/#/login")
t.showMainWindow()
t.window.Show()
t.window.Focus()
}
return
}

View File

@@ -5,9 +5,19 @@ package main
import (
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/ui/services"
)
func (t *Tray) onStatusEvent(ev *application.CustomEvent) {
st, ok := ev.Data.(services.Status)
if !ok {
return
}
t.applyStatus(st)
}
// applyStatus repaints the tray from a daemon snapshot. Icon refresh is skipped
// when no icon-relevant input changed: the daemon emits rapid SubscribeStatus
// bursts during health probes that would otherwise spam Shell_NotifyIcon.

View File

@@ -28,9 +28,6 @@ type trayUpdater struct {
// About submenu, which KDE/Plasma caches on first open and never re-fetches
// on a plain SetLabel/SetHidden — only a relayout (fresh submenu ids) repaints.
onMenuChange func()
// showMain brings the main window forward via the WindowManager (status
// replay + centering); nil falls back to a bare window.Show.
showMain func()
mu sync.Mutex
item *application.MenuItem
@@ -39,7 +36,7 @@ type trayUpdater struct {
progressWindowOpen bool
}
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange, onMenuChange, showMain func()) *trayUpdater {
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
u := &trayUpdater{
app: app,
window: window,
@@ -48,7 +45,6 @@ func newTrayUpdater(app *application.App, window *application.WebviewWindow, upd
loc: loc,
onIconChange: onIconChange,
onMenuChange: onMenuChange,
showMain: showMain,
}
app.Event.On(updater.EventStateChanged, u.onStateEvent)
// Seed from cached state to cover an event that fired before wiring completed.
@@ -197,10 +193,6 @@ func (u *trayUpdater) openProgressWindow(version string) {
url += "?version=" + version
}
u.window.SetURL(url)
if u.showMain != nil {
u.showMain()
return
}
u.window.Show()
u.window.Focus()
}