fix(ui): install X11 error handler so tray races don't kill the UI

Xlib's default protocol-error handler calls exit() on any async X error,
so a tray race (tray manager window dying between find and dock, XMoveWindow
on a popup the WM already destroyed) would take the whole UI process down.

Install process-global logging no-op handlers via XSetErrorHandler/
XSetIOErrorHandler after every XOpenDisplay. Since the handler slot is
process-global and GDK init can clobber it, additionally wrap the popup
code's raw Xlib calls on GDK's Display in gdk_x11_display_error_trap_push/
pop_ignored, which is independent of the global handler.
This commit is contained in:
Zoltán Papp
2026-06-15 12:26:53 +02:00
parent 2089b79630
commit b0f08645c3
4 changed files with 58 additions and 0 deletions
+2
View File
@@ -5,4 +5,6 @@ Minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the AppIndicator e
- `tray_watcher_linux.go` — owns `org.kde.StatusNotifierWatcher` on the session bus if no other process has it. Safe to call unconditionally. - `tray_watcher_linux.go` — owns `org.kde.StatusNotifierWatcher` on the session bus if no other process has it. Safe to call unconditionally.
- `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` — when an XEmbed tray (`_NET_SYSTEM_TRAY_S0`) is available, also start an in-process XEmbed host that bridges the SNI icon into the XEmbed tray. Reads `IconPixmap` over D-Bus, draws via cairo+X11, polls for clicks, fetches `com.canonical.dbusmenu.GetLayout` for the popup menu, fires `com.canonical.dbusmenu.Event` on click. - `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` — when an XEmbed tray (`_NET_SYSTEM_TRAY_S0`) is available, also start an in-process XEmbed host that bridges the SNI icon into the XEmbed tray. Reads `IconPixmap` over D-Bus, draws via cairo+X11, polls for clicks, fetches `com.canonical.dbusmenu.GetLayout` for the popup menu, fires `com.canonical.dbusmenu.Event` on click.
**X error handling.** Xlib's default protocol-error handler calls `exit()`, so a single async X error from a tray race (tray manager window dying between `xembed_find_tray` and `xembed_dock`; `XMoveWindow` on a popup the WM already destroyed) would take the whole UI process down. `xembed_install_error_handlers()` (`xembed_tray_linux.c`) installs process-global logging no-op handlers via `XSetErrorHandler`/`XSetIOErrorHandler`; it's idempotent and called from the Go side after every `XOpenDisplay` (`newXembedHost`, `xembedTrayAvailable`). Because the handler slot is process-global, GTK's GDK init can clobber it (whoever installs last wins) — so the popup code's raw Xlib calls **on GDK's Display** (`x11_move_window`, the submenu `XTranslateCoordinates`) are additionally wrapped in GDK's own `gdk_x11_display_error_trap_push`/`_pop_ignored`, which is independent of the global handler. (Those GDK X11 backend calls warn `-Wdeprecated-declarations` in GTK4, like the existing `gdk_x11_surface_get_xid`/`gdk_x11_display_get_xdisplay` calls — expected, still functional.)
Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op. Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op.
+2
View File
@@ -73,6 +73,7 @@ func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*x
if dpy == nil { if dpy == nil {
return nil, errors.New("cannot open X display") return nil, errors.New("cannot open X display")
} }
C.xembed_install_error_handlers()
screen := C.xembed_default_screen(dpy) screen := C.xembed_default_screen(dpy)
trayMgr := C.xembed_find_tray(dpy, screen) trayMgr := C.xembed_find_tray(dpy, screen)
@@ -383,6 +384,7 @@ func xembedTrayAvailable() bool {
if dpy == nil { if dpy == nil {
return false return false
} }
C.xembed_install_error_handlers()
defer C.XCloseDisplay(dpy) defer C.XCloseDisplay(dpy)
screen := C.xembed_default_screen(dpy) screen := C.xembed_default_screen(dpy)
return C.xembed_find_tray(dpy, screen) != 0 return C.xembed_find_tray(dpy, screen) != 0
+48
View File
@@ -13,6 +13,41 @@
#define SYSTEM_TRAY_REQUEST_DOCK 0 #define SYSTEM_TRAY_REQUEST_DOCK 0
#define XEMBED_MAPPED (1 << 0) #define XEMBED_MAPPED (1 << 0)
/* Xlib's default protocol-error handler calls exit() on any async X error,
killing the whole UI process. Handlers are process-global (not
per-Display), so a single install covers our raw tray Display and GDK's.
Tray work is full of races the X server reports asynchronously — the tray
manager window dying between xembed_find_tray and xembed_dock (BadWindow
on XSendEvent), or x11_move_window touching a popup the WM already
destroyed — and the default handler would take us down for any of them.
Returning 0 here makes the error a logged no-op instead. */
static int xembed_x_error_handler(Display *dpy, XErrorEvent *ev) {
char buf[256];
XGetErrorText(dpy, ev->error_code, buf, sizeof(buf));
fprintf(stderr,
"xembed: X error (ignored): %s (code=%d, request=%d.%d, resource=0x%lx)\n",
buf, ev->error_code, ev->request_code, ev->minor_code,
ev->resourceid);
return 0;
}
/* The I/O error handler fires when the X connection itself drops (server
gone, socket closed). Xlib treats this as fatal and exits even if we
return, so this can't keep the process alive — it only logs a clearer
line than Xlib's terse default before the unavoidable exit. */
static int xembed_x_io_error_handler(Display *dpy) {
(void)dpy;
fprintf(stderr, "xembed: X I/O error (connection lost)\n");
return 0;
}
/* Install the process-global handlers. Idempotent and cheap, so callers may
invoke it after every XOpenDisplay without tracking prior installs. */
void xembed_install_error_handlers(void) {
XSetErrorHandler(xembed_x_error_handler);
XSetIOErrorHandler(xembed_x_io_error_handler);
}
Window xembed_find_tray(Display *dpy, int screen) { Window xembed_find_tray(Display *dpy, int screen) {
char atom_name[64]; char atom_name[64];
snprintf(atom_name, sizeof(atom_name), "_NET_SYSTEM_TRAY_S%d", screen); snprintf(atom_name, sizeof(atom_name), "_NET_SYSTEM_TRAY_S%d", screen);
@@ -363,6 +398,14 @@ static void x11_move_window(GtkWidget *win, int x, int y) {
GdkDisplay *display = gdk_surface_get_display(surface); GdkDisplay *display = gdk_surface_get_display(surface);
Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display)); Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display));
/* These calls poke a window the WM may have already destroyed (a popup
torn down between scheduling and this idle callback). On GDK's Display
use GDK's own error trap rather than our global handler — push/pop is
the spec-correct way to make untrapped BadWindow/BadMatch from these
raw Xlib calls non-fatal, independent of whichever process-global
handler happens to be installed. */
gdk_x11_display_error_trap_push(display);
/* _NET_WM_WINDOW_TYPE_POPUP_MENU: makes fluxbox / openbox / etc /* _NET_WM_WINDOW_TYPE_POPUP_MENU: makes fluxbox / openbox / etc
render the window above panels and skip decorations. Must be render the window above panels and skip decorations. Must be
set before the window is mapped to be honoured by some WMs; set before the window is mapped to be honoured by some WMs;
@@ -409,6 +452,7 @@ static void x11_move_window(GtkWidget *win, int x, int y) {
(XEvent *)&ev); (XEvent *)&ev);
XFlush(xdpy); XFlush(xdpy);
gdk_x11_display_error_trap_pop_ignored(display);
} }
/* Forward declaration — submenu buttons need to schedule a child popup. */ /* Forward declaration — submenu buttons need to schedule a child popup. */
@@ -453,8 +497,12 @@ static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) {
GdkDisplay *display = gdk_surface_get_display(anchor_surface); GdkDisplay *display = gdk_surface_get_display(anchor_surface);
Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display)); Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display));
Window child; Window child;
/* Trap BadWindow in case the anchor's surface is torn down between
the click and this handler running. */
gdk_x11_display_error_trap_push(display);
XTranslateCoordinates(xdpy, axid, DefaultRootWindow(xdpy), XTranslateCoordinates(xdpy, axid, DefaultRootWindow(xdpy),
0, 0, &ox, &oy, &child); 0, 0, &ox, &oy, &child);
gdk_x11_display_error_trap_pop_ignored(display);
} }
int ax = ox + (int)bounds.origin.x; int ax = ox + (int)bounds.origin.x;
int ay = oy + (int)bounds.origin.y; int ay = oy + (int)bounds.origin.y;
+6
View File
@@ -8,6 +8,12 @@ static inline int xembed_default_screen(Display *dpy) {
return DefaultScreen(dpy); return DefaultScreen(dpy);
} }
// xembed_install_error_handlers replaces Xlib's default protocol- and
// I/O-error handlers (which call exit()) with logging handlers, so an async
// X error from a tray race doesn't kill the UI process. Process-global and
// idempotent; safe to call after every XOpenDisplay.
void xembed_install_error_handlers(void);
// xembed_find_tray returns the selection owner window for // xembed_find_tray returns the selection owner window for
// _NET_SYSTEM_TRAY_S{screen}, or 0 if no XEmbed tray manager exists. // _NET_SYSTEM_TRAY_S{screen}, or 0 if no XEmbed tray manager exists.
Window xembed_find_tray(Display *dpy, int screen); Window xembed_find_tray(Display *dpy, int screen);