mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-25 22:09:08 +01:00
[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:
@@ -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`.
|
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
|
### 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.
|
`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.
|
||||||
|
|||||||
+14
-1
@@ -190,7 +190,20 @@ func main() {
|
|||||||
})
|
})
|
||||||
listenForShowSignal(context.Background(), tray)
|
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 {
|
if err := app.Run(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|||||||
+2
-4
@@ -637,13 +637,11 @@ func (t *Tray) notify(title, body, id string) {
|
|||||||
if t.svc.Notifier == nil {
|
if t.svc.Notifier == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{
|
_ = safeSendNotification(t.svc.Notifier.SendNotification, title, notifications.NotificationOptions{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: title,
|
Title: title,
|
||||||
Body: body,
|
Body: body,
|
||||||
}); err != nil {
|
})
|
||||||
log.Debugf("notify %q: %v", title, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyError fires a generic "Error" notification for tray-driven action
|
// notifyError fires a generic "Error" notification for tray-driven action
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -224,16 +224,17 @@ func (t *Tray) notifySessionWarning(title, body string) {
|
|||||||
if t.svc.Notifier == nil {
|
if t.svc.Notifier == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := t.svc.Notifier.SendNotificationWithActions(notifications.NotificationOptions{
|
err := safeSendNotification(t.svc.Notifier.SendNotificationWithActions, "session-warning with actions", notifications.NotificationOptions{
|
||||||
ID: notifyIDSessionWarning,
|
ID: notifyIDSessionWarning,
|
||||||
Title: title,
|
Title: title,
|
||||||
Body: body,
|
Body: body,
|
||||||
CategoryID: notifyCategorySessionWarning,
|
CategoryID: notifyCategorySessionWarning,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("notify session-warning with actions: %v", err)
|
|
||||||
// Fall back to a plain notification so the user at least gets
|
// 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)
|
t.notify(title, body, notifyIDSessionWarning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,13 +202,11 @@ func (u *trayUpdater) sendUpdateNotification(st updater.State) {
|
|||||||
if st.Enforced {
|
if st.Enforced {
|
||||||
body += u.loc.T("notify.update.enforcedSuffix")
|
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,
|
ID: notifyIDUpdatePrefix + st.Version,
|
||||||
Title: u.loc.T("notify.update.title"),
|
Title: u.loc.T("notify.update.title"),
|
||||||
Body: body,
|
Body: body,
|
||||||
}); err != nil {
|
})
|
||||||
log.Debugf("send update notification: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// openProgressWindow points the main window at the /update progress page
|
// openProgressWindow points the main window at the /update progress page
|
||||||
|
|||||||
Reference in New Issue
Block a user