From 4769fec49d5f5066112af31d72aeca40e38dda90 Mon Sep 17 00:00:00 2001 From: liuzaisen Date: Sun, 20 Sep 2026 17:16:33 +0800 Subject: [PATCH] fix(widget): recover delayed X11 frame-sync stalls --- .github/scripts/package-linux-deb.sh | 2 +- README.md | 3 +- desktop/display_name_test.go | 1 + desktop/widget_window_linux.go | 191 ++++++++++++++++-- .../specs/2026-08-10-desk-widget-design.md | 21 +- 5 files changed, 194 insertions(+), 24 deletions(-) diff --git a/.github/scripts/package-linux-deb.sh b/.github/scripts/package-linux-deb.sh index ea2eeef6..b0e720e7 100755 --- a/.github/scripts/package-linux-deb.sh +++ b/.github/scripts/package-linux-deb.sh @@ -32,7 +32,7 @@ Priority: optional Architecture: ${ARCH} Maintainer: liuzaisen Installed-Size: ${installed_size} -Depends: libgtk-3-0, libwebkit2gtk-4.1-0 +Depends: libgtk-3-0, libwebkit2gtk-4.1-0, libxext6 Description: AT Term desktop app Cross-platform terminal emulator with attachable synced sessions. CONTROL diff --git a/README.md b/README.md index d82bb05d..18537741 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ go run ./cmd/atterm-relay --addr 127.0.0.1:8080 --dev-insecure # 终端 1 cd desktop && wails dev -tags webkit2_41 # 终端 2(macOS/Windows 省略 -tags) ``` -依赖 Go 1.23+ / Node 20+ / Wails v2.12.0。Linux 需 `libgtk-3-dev libwebkit2gtk-4.1-dev`。 +依赖 Go 1.23+ / Node 20+ / Wails v2.12.0。Linux 需 +`libgtk-3-dev libwebkit2gtk-4.1-dev libxext-dev`。 --- diff --git a/desktop/display_name_test.go b/desktop/display_name_test.go index 9cc4a146..4d4033c6 100644 --- a/desktop/display_name_test.go +++ b/desktop/display_name_test.go @@ -58,6 +58,7 @@ func TestInstallersUseATTermNameAndIcons(t *testing.T) { `Exec=AT-Term`, `Icon=AT-Term`, `/usr/share/icons/hicolor/1024x1024/apps/AT-Term.png`, + `libxext6`, }, "../.github/scripts/package-macos-dmg.sh": { `AT Term.app`, diff --git a/desktop/widget_window_linux.go b/desktop/widget_window_linux.go index 381bcbde..7c11f339 100644 --- a/desktop/widget_window_linux.go +++ b/desktop/widget_window_linux.go @@ -3,9 +3,156 @@ package main /* -#cgo pkg-config: gtk+-3.0 +#cgo pkg-config: gtk+-3.0 xext #include #include +#include +#include + +typedef struct { + GWeakRef widget; + guint odd_samples; +} AttermWidgetFrameWatch; + +typedef struct { + GtkWidget *widget; + gint x; + gint y; + gint width; + gint height; +} AttermWidgetRecovery; + +static gboolean atterm_widget_clear_attention(gpointer data) { + GtkWidget *widget = GTK_WIDGET(data); + gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE); + GdkWindow *window = gtk_widget_get_window(widget); + if (window == NULL || !GDK_IS_X11_WINDOW(window)) + return G_SOURCE_REMOVE; + + Display *display = GDK_WINDOW_XDISPLAY(window); + XEvent event = {0}; + event.xclient.type = ClientMessage; + event.xclient.window = GDK_WINDOW_XID(window); + event.xclient.message_type = XInternAtom(display, "_NET_WM_STATE", False); + event.xclient.format = 32; + event.xclient.data.l[0] = 0; // _NET_WM_STATE_REMOVE + event.xclient.data.l[1] = XInternAtom(display, + "_NET_WM_STATE_DEMANDS_ATTENTION", False); + event.xclient.data.l[3] = 1; // application-originated request + XSendEvent(display, DefaultRootWindow(display), False, + SubstructureRedirectMask | SubstructureNotifyMask, &event); + XFlush(display); + return G_SOURCE_REMOVE; +} + +static gboolean atterm_widget_show_after_stall(gpointer data) { + AttermWidgetRecovery *recovery = data; + GtkWindow *gtk_window = GTK_WINDOW(recovery->widget); + gtk_window_move(gtk_window, recovery->x, recovery->y); + gtk_window_resize(gtk_window, recovery->width, recovery->height); + gtk_widget_show(recovery->widget); + // Mutter may treat a background remap as a request for attention and may + // choose a fresh placement. This recovery is invisible maintenance, so + // restore the user's geometry and clear that hint after mapping too. + gtk_window_move(gtk_window, recovery->x, recovery->y); + gtk_window_set_urgency_hint(gtk_window, FALSE); + // Mutter adds DEMANDS_ATTENTION asynchronously after MapNotify when a + // background application remaps a window. Remove it after that WM turn. + g_timeout_add_full(G_PRIORITY_DEFAULT, 250, + atterm_widget_clear_attention, g_object_ref(recovery->widget), g_object_unref); + GdkWindow *window = gtk_widget_get_window(recovery->widget); + if (window != NULL && GDK_IS_X11_WINDOW(window)) + gdk_x11_window_set_frame_sync_enabled(window, FALSE); + return G_SOURCE_REMOVE; +} + +static void atterm_widget_recovery_free(gpointer data) { + AttermWidgetRecovery *recovery = data; + g_object_unref(recovery->widget); + g_free(recovery); +} + +static gboolean atterm_widget_extended_counter_is_odd(GdkWindow *window, + gboolean *is_odd) { + Display *display = GDK_WINDOW_XDISPLAY(window); + Atom property = XInternAtom(display, "_NET_WM_SYNC_REQUEST_COUNTER", False); + Atom actual_type = None; + int actual_format = 0; + unsigned long item_count = 0; + unsigned long bytes_after = 0; + unsigned char *raw = NULL; + + int status = XGetWindowProperty(display, GDK_WINDOW_XID(window), property, + 0, 2, False, XA_CARDINAL, &actual_type, &actual_format, + &item_count, &bytes_after, &raw); + if (status != Success || actual_type != XA_CARDINAL || + actual_format != 32 || item_count < 2 || raw == NULL) { + if (raw != NULL) + XFree(raw); + return FALSE; + } + + XSyncCounter extended = (XSyncCounter)((unsigned long *)raw)[1]; + XFree(raw); + XSyncValue value; + if (!XSyncQueryCounter(display, extended, &value)) + return FALSE; + *is_odd = (XSyncValueLow32(value) & 1U) != 0; + return TRUE; +} + +static gboolean atterm_widget_watch_frames(gpointer data) { + AttermWidgetFrameWatch *watch = data; + GtkWidget *widget = g_weak_ref_get(&watch->widget); + if (widget == NULL) + return G_SOURCE_REMOVE; + + GdkWindow *window = gtk_widget_get_window(widget); + gboolean odd = FALSE; + if (!gtk_widget_get_mapped(widget) || window == NULL || + !GDK_IS_X11_WINDOW(window) || + !atterm_widget_extended_counter_is_odd(window, &odd)) { + watch->odd_samples = 0; + g_object_unref(widget); + return G_SOURCE_CONTINUE; + } + + watch->odd_samples = odd ? watch->odd_samples + 1 : 0; + if (watch->odd_samples >= 3) { + watch->odd_samples = 0; + AttermWidgetRecovery *recovery = g_new0(AttermWidgetRecovery, 1); + recovery->widget = g_object_ref(widget); + gtk_window_get_position(GTK_WINDOW(widget), &recovery->x, &recovery->y); + gtk_window_get_size(GTK_WINDOW(widget), &recovery->width, &recovery->height); + // GTK clears frame_pending and thaws the frame clock from its + // UnmapNotify handler. Showing on a later main-loop turn ensures the + // unmap is observed before another frame is requested. + gtk_widget_hide(widget); + g_timeout_add_full(G_PRIORITY_DEFAULT, 100, + atterm_widget_show_after_stall, recovery, atterm_widget_recovery_free); + } + + g_object_unref(widget); + return G_SOURCE_CONTINUE; +} + +static void atterm_widget_frame_watch_free(gpointer data) { + AttermWidgetFrameWatch *watch = data; + g_weak_ref_clear(&watch->widget); + g_free(watch); +} + +static void atterm_widget_ensure_frame_watch(GtkWidget *widget) { + static const char *watch_key = "atterm-widget-frame-watch"; + if (g_object_get_data(G_OBJECT(widget), watch_key) != NULL) + return; + + AttermWidgetFrameWatch *watch = g_new0(AttermWidgetFrameWatch, 1); + g_weak_ref_init(&watch->widget, G_OBJECT(widget)); + g_object_set_data(G_OBJECT(widget), watch_key, GINT_TO_POINTER(1)); + g_timeout_add_seconds_full(G_PRIORITY_DEFAULT, 1, + atterm_widget_watch_frames, watch, atterm_widget_frame_watch_free); +} static gboolean atterm_widget_disable_frame_sync(gpointer data) { guint *attempts = data; @@ -14,9 +161,13 @@ static gboolean atterm_widget_disable_frame_sync(gpointer data) { gboolean applied = FALSE; for (GList *item = windows; item != NULL; item = item->next) { GtkWidget *widget = GTK_WIDGET(item->data); + if (!GTK_IS_WINDOW(widget) || + g_strcmp0(gtk_window_get_title(GTK_WINDOW(widget)), "AT Term Widget") != 0) + continue; GdkWindow *window = gtk_widget_get_window(widget); if (window != NULL && GDK_IS_X11_WINDOW(window)) { gdk_x11_window_set_frame_sync_enabled(window, FALSE); + atterm_widget_ensure_frame_watch(widget); applied = TRUE; } } @@ -29,14 +180,19 @@ static gboolean atterm_widget_disable_frame_sync(gpointer data) { g_free(attempts); return G_SOURCE_REMOVE; } - return G_SOURCE_CONTINUE; + // Keep every retry one-shot. A high-priority idle source that returned + // CONTINUE here would spin and starve the GTK realization it is waiting for. + g_timeout_add_full(G_PRIORITY_DEFAULT, 10, + atterm_widget_disable_frame_sync, attempts, NULL); + return G_SOURCE_REMOVE; } static void atterm_widget_schedule_disable_frame_sync(void) { - // An idle callback can spin if the window has not been realized yet. A - // short timeout retries without consuming a core and is cancelled as soon - // as the first X11 toplevel is updated. - g_timeout_add(50, atterm_widget_disable_frame_sync, g_new0(guint, 1)); + // Run before GTK's redraw idles. Disabling after the first synchronized frame + // is already pending only prevents the next freeze; it does not thaw the + // frame clock that is currently waiting for Mutter's acknowledgement. + g_idle_add_full(G_PRIORITY_DEFAULT, atterm_widget_disable_frame_sync, + g_new0(guint, 1), NULL); } */ import "C" @@ -65,17 +221,18 @@ func applyWidgetPlatformOptions(opts *options.App) { } } -// applyWidgetPostStartup intentionally does nothing on Linux. The GTK window -// may not be realized when Wails invokes OnStartup, so the X11 frame-sync -// operation is deferred to applyWidgetPostReady below. -func applyWidgetPostStartup(_ context.Context) {} - -// applyWidgetPostReady runs after the frontend has mounted. Wails invokes the -// bound method on a Go goroutine, so GTK work is queued onto the main loop. -// Mutter's X11 frame-sync -// protocol can stop acknowledging a window after its first frame; GTK then -// keeps the process responsive but suppresses every subsequent draw. The -// widget does not need compositor pacing, so opt its sole toplevel out. +// applyWidgetPostStartup starts the bounded realization retry before the hidden +// webview paints its first frame. It also installs the XSync watchdog that +// recovers the runtime stall where Mutter leaves a frame's extended update +// counter odd indefinitely. +func applyWidgetPostStartup(_ context.Context) { + C.atterm_widget_schedule_disable_frame_sync() +} + +// applyWidgetPostReady is a second bounded attempt after the frontend has +// mounted. It covers unusual startup ordering where OnStartup's retry budget +// expires before Wails realizes the native window. Wails invokes both hooks on +// Go goroutines, so GTK work is always queued onto the main loop. func applyWidgetPostReady(_ context.Context) { C.atterm_widget_schedule_disable_frame_sync() } diff --git a/docs/superpowers/specs/2026-08-10-desk-widget-design.md b/docs/superpowers/specs/2026-08-10-desk-widget-design.md index e3c3cd47..be8320e0 100644 --- a/docs/superpowers/specs/2026-08-10-desk-widget-design.md +++ b/docs/superpowers/specs/2026-08-10-desk-widget-design.md @@ -188,11 +188,22 @@ Wails IPC 和 WebKit DOM 此时都仍然存活,所以新的状态事件不会 2. 不含 WebKit 的纯 GTK label 程序也复现:GLib tick 持续,`draw` 只在启动时发生。 3. 同一程序在无 Mutter 的 Xephyr 中持续出帧。 4. 只关闭该窗口的 GDK X11 frame sync 后,宿主会话中每个 tick 都重新触发 `draw`。 - -修复位于 `desktop/widget_window_linux.go::applyWidgetPostStartup`。Wails 从 Go goroutine 调 -`OnStartup`,因此必须用 `g_idle_add` 把 GTK 操作派发到主循环;挂件是独立子进程,只枚举并 -修改该进程的唯一顶层窗口,不影响 AT Term 主窗口。不要用定时重启、WebKit reload、GPU flag -或强制 queue-draw 掩盖:frame clock 仍在等待时,这些操作都不能完成新帧。 +5. 长时间运行后再次冻结的现场里,`_NET_WM_SYNC_REQUEST_COUNTER` 的 extended counter 停在 + 奇数且不再变化;同进程主窗口的 counter 持续以偶数递增。对挂件做一次 unmap/remap 后, + counter 立即恢复递增且动画继续,证明进程、stdin、Vue 都没死,卡住的是单帧生命周期。 + +修复位于 `desktop/widget_window_linux.go::applyWidgetPostStartup` + `applyWidgetPostReady`。 +Wails 从 Go goroutine 调这两个 hook,因此必须把 GTK 操作派发到主循环;挂件是独立子进程, +只修改标题为 `AT Term Widget` 的顶层窗口,不影响 AT Term 主窗口。`OnStartup` 的有界重试是 +主路径,必须赶在隐藏 WebView 的首帧前关闭同步:`gdk_x11_window_set_frame_sync_enabled(FALSE)` +只影响后续帧,不能解冻已经在等 `_NET_WM_FRAME_DRAWN` 的 frame clock。`Ready` 再跑一次仅作 +native window 延迟 realization 的兜底。运行期另有 XSync watchdog 每秒读取 extended counter; +正常帧的奇数阶段只持续数毫秒,只有连续 3 次采样仍为奇数才执行一次 GTK hide/show。GTK 的 +`UnmapNotify` 路径会清 `frame_pending` 并 thaw frame clock,下一 main-loop turn 映射窗口前再 +关闭 frame sync。恢复前必须保存窗口位置和尺寸、remap 后还原并清 urgency hint;否则 Mutter +可能把挂件重新摆放并加 `_NET_WM_STATE_DEMANDS_ATTENTION`。后者由 Mutter 在 MapNotify 后异步 +添加,必须延后一轮通过标准 EWMH `_NET_WM_STATE_REMOVE` 消息清掉。不要用定时重启、WebKit +reload、GPU flag 或单纯 queue-draw 掩盖:frame clock 仍在等待时,这些操作都不能完成新帧。 ## Interaction