[client] Fix tray notification crash on Linux

The Wails notifications service connects to the D-Bus session bus in its
ServiceStartup, which Wails runs synchronously inside app.Run. daemonFeed.Watch
was started before app.Run, so the first daemon SubscribeEvents message (which
replays the cached available-update state) fanned out to the tray's update-state
listener and fired an OS notification before that startup ran. The notifier's
*dbus.Conn was still nil, so SendNotification nil-dereferenced deep in godbus and
the panic was fatal to the whole process (observed on Linux Mint).

Move daemonFeed.Watch into the ApplicationStarted hook so it runs after the
service-startup loop, and route every notification send through a new
safeSendNotification helper that recovers from a panic and logs it, so a broken
or unavailable notification bus degrades to a skipped toast instead of crashing.
This commit is contained in:
Zoltán Papp
2026-06-10 16:49:12 +02:00
parent 439b85c584
commit 8a4e686098
6 changed files with 63 additions and 12 deletions

View File

@@ -141,6 +141,8 @@ User-actionable operation failures (config save, profile switch, debug bundle, u
The tray uses Wails' built-in `notifications` service. One `notifications.NotificationService` is created in `main.go` and passed into `TrayServices.Notifier`. Notification IDs are prefixed for coalescing: `netbird-update-<version>`, `netbird-event-<id>`, `netbird-tray-error`, `netbird-session-expired`. Notifications are gated by the user's "Notifications" toggle (cached in `Tray.notificationsEnabled`, seeded from `Settings.GetConfig` at boot). `Severity == "critical"` events bypass the gate, mirroring the legacy Fyne `event.Manager`.
**All sends go through `safeSendNotification` (`tray_notify.go`)** — never call `Notifier.SendNotification`/`SendNotificationWithActions` directly. It swallows both errors *and panics*. The panic guard is load-bearing on Linux: Wails' notifier connects to the session bus in its `ServiceStartup`, and when that connect fails (headless box, no/unreachable `DBUS_SESSION_BUS_ADDRESS`, UI launched outside a desktop session) Wails logs the error but leaves the service registered with a **nil `*dbus.Conn`**. The next send then nil-derefs deep inside godbus (`Conn.getSerial`), and because sends run on a Wails event-dispatch goroutine the panic is fatal to the whole process — observed as a "catastrophic failure" crash on a Linux Mint VM when an update toast fired (`tray_update.go sendUpdateNotification`). `recover()` turns it into a logged no-op. The `tray_session.go` plain-notification fallback keys off the returned error, so a recovered panic (returns nil) correctly skips the fallback — the bus is dead, the plain send would panic too.
### Profile switching invariants
`ProfileSwitcher.SwitchActive` is the only switch path on the TS side — `ProfileContext.switchProfile` is the single TS wrapper, and `modules/profiles/ProfilesTab.tsx` + the header `ProfileDropdown` both go through it. The Go side captures `prevStatus`, drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`, mirrors into the user-side `profilemanager`, and conditionally fires Down/Up per the reconnect-policy table above.

View File

@@ -190,7 +190,20 @@ func main() {
})
listenForShowSignal(context.Background(), tray)
daemonFeed.Watch(context.Background())
// Start the daemon event feed only after Wails has run every service's
// ServiceStartup. The very first daemon SubscribeEvents message replays
// the cached state (status + available update) synchronously, which fans
// out through app.Event into the tray's update-state listener and fires an
// OS notification. If Watch ran before app.Run, that send could beat the
// notifications service's ServiceStartup — on Linux the Wails notifier
// connects to the session bus there, so its *dbus.Conn would still be nil
// and SendNotification would nil-deref (fatal panic on the event-dispatch
// goroutine; observed on Linux Mint). ApplicationStarted fires inside
// app.Run after the synchronous service-startup loop, so the bus is up by
// the time the first event lands.
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
daemonFeed.Watch(context.Background())
})
if err := app.Run(); err != nil {
log.Fatal(err)

View File

@@ -637,13 +637,11 @@ func (t *Tray) notify(title, body, id string) {
if t.svc.Notifier == nil {
return
}
if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{
_ = safeSendNotification(t.svc.Notifier.SendNotification, title, notifications.NotificationOptions{
ID: id,
Title: title,
Body: body,
}); err != nil {
log.Debugf("notify %q: %v", title, err)
}
})
}
// notifyError fires a generic "Error" notification for tray-driven action

39
client/ui/tray_notify.go Normal file
View File

@@ -0,0 +1,39 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
// sendFn is either NotificationService.SendNotification or
// SendNotificationWithActions — both share the same signature.
type sendFn func(notifications.NotificationOptions) error
// safeSendNotification dispatches an OS notification, swallowing both errors
// and panics. OS toasts are best-effort: a missing or broken session bus must
// never crash the app.
//
// The panic guard is load-bearing on Linux. Wails' notifier connects to the
// session bus in its ServiceStartup; when that connect fails (headless box,
// no/unreachable DBUS_SESSION_BUS_ADDRESS, a UI launched outside a desktop
// session), Wails logs the error but leaves the service registered with a nil
// *dbus.Conn. The next SendNotification then nil-derefs deep inside
// godbus (Conn.getSerial) and, because the send runs on a Wails event-dispatch
// goroutine, the panic is fatal to the whole process rather than to one event
// listener. recover() turns that into a logged no-op. See
// notifications_linux.go in wails v3.
func safeSendNotification(send sendFn, what string, opts notifications.NotificationOptions) (err error) {
defer func() {
if r := recover(); r != nil {
log.Errorf("notify %s: recovered from panic (notification bus unavailable): %v", what, r)
err = nil
}
}()
if err := send(opts); err != nil {
log.Errorf("notify %s: %v", what, err)
return err
}
return nil
}

View File

@@ -224,16 +224,17 @@ func (t *Tray) notifySessionWarning(title, body string) {
if t.svc.Notifier == nil {
return
}
err := t.svc.Notifier.SendNotificationWithActions(notifications.NotificationOptions{
err := safeSendNotification(t.svc.Notifier.SendNotificationWithActions, "session-warning with actions", notifications.NotificationOptions{
ID: notifyIDSessionWarning,
Title: title,
Body: body,
CategoryID: notifyCategorySessionWarning,
})
if err != nil {
log.Debugf("notify session-warning with actions: %v", err)
// Fall back to a plain notification so the user at least gets
// the warning text, even without buttons.
// the warning text, even without buttons. (A nil err here also
// covers the panic-recovered case, where the bus is dead and the
// plain fallback would fail too — so we correctly skip it.)
t.notify(title, body, notifyIDSessionWarning)
}
}

View File

@@ -202,13 +202,11 @@ func (u *trayUpdater) sendUpdateNotification(st updater.State) {
if st.Enforced {
body += u.loc.T("notify.update.enforcedSuffix")
}
if err := u.notifier.SendNotification(notifications.NotificationOptions{
_ = safeSendNotification(u.notifier.SendNotification, "update", notifications.NotificationOptions{
ID: notifyIDUpdatePrefix + st.Version,
Title: u.loc.T("notify.update.title"),
Body: body,
}); err != nil {
log.Debugf("send update notification: %v", err)
}
})
}
// openProgressWindow points the main window at the /update progress page