阅前提醒
本文有较长的分析过程和大量的底层协议分析和对应代码讲解,请在清醒状态下尝试阅读本文。
来自 Niri 的神秘的 Bug
在我的音乐软件 Rustle 发布到 GitHub 上的一个月不到,我就连续收到了 3 条 issue, 真是受宠若惊,不过这三个都是难修至极的 bug, 尤其是这个(新标签页打开)。让人震惊的是我在 Hyprland 下从来没有遇到过这样的情况,为什么 Niri 会有这个协议错误但是 Hyprland 没有呢?不都是 Wayland 吗?
xdg_surface error 3: must ack the initial configure before attaching buffer
Wayland 窗口协议
概念定义
1. wl_surface:原始的画布
- 职责:它只负责维护 Buffer(像素数据)、处理损伤区域(Damaged regions)和接收渲染同步信号(Frame callbacks)。
- 状态:它是协议无关的。它不知道自己是一个窗口、一个图标、还是一个右键菜单。
- 生命周期:它是持久存在的。即便窗口隐藏了,只要应用存在,就依然在内存里 。
- 操作:unmap wl_surface 之后应用界面消失,remap wl_surface 之后可以重新显示。unmap 的操作是
attach(None)之后commit(),remap 的操作是attach(buffer)之后commit()
2. xdg_surface:通用的“窗口化”入口
当决定让一个 wl_surface 变成某种意义上的“窗口”时,你必须先为它创建一个 xdg_surface。
- 职责(中间人):它是底层的
wl_surface与xdg_shell协议之间的协调层。 - 核心功能:Configure 机制。它处理合成器发来的配置请求。只有当
xdg_surface确认了配置(Ack configure),合成器才会允许把内容显示出来。 - 意义:它本身不代表具体窗口,它是
xdg_toplevel(顶层窗口)或xdg_popup(弹出菜单)的共同基类。 - 操作:
xdg_surface.configure(serial)和xdg_surface.ack_configure(serial)等,前者是 compositor 主动调用的,是合成器的实现,后者是 client 接收用的,是本软件所用 Wayland client 即 SCTK 实现的,本文遇到的问题与此相关。
3. xdg_toplevel:真正的“顶层窗口”角色
这是我们在桌面交互中感知到的那个“窗口”。
- 职责(管理权限):它负责一切与桌面环境交互的行为。
- 设置元数据:标题(Title)、应用 ID(App ID) 。
- 窗口状态:最大化、最小化、全屏、交互式缩放 。
- 约束:它不能独立存在,必须依附于一个
xdg_surface。 - 操作:
set_title,set_app_id,set_maximized,unset_maximized,set_fullscreen,unset_fullscreen,,set_minimizedxdg_toplevel.configure(width, height, states), 本文遇到的协议错误是 xdg_surface 的,不是 toplevel 的 configure 引起的。
Wayland 的最小化机制
知道了 xdg_toplevel 是负责控制最小化的,那么我们在 xdg_shell 里面寻找最小化请求1:
<request name="set_minimized">
<description summary="set the window as minimized">
Request that the compositor minimize your surface. There is no
way to know if the surface is currently minimized, nor is there
any way to unset minimization on this surface.
If you are looking to throttle redrawing when minimized, please
instead use the wl_surface.frame event for this, as this will
also work with live previews on windows in Alt-Tab, Expose or
similar compositor features.
</description>
</request>
竟然如此诡异!为什么定义了 set_minimized 但是没有 unset_minimized 而且应用也无法知道自己是否被最小化了?何意味?另外,这个 API 也只是请求最小化,经过测试,Hyprland 和 Niri 都直接忽视了这个请求。
也就是说,Wayland 官方没有给最小化和还原的功能!但是这很明显不符合我们平常的使用体验:在 Windows 和 MacOS 上,使用最小化是最常见不过的手段,然后应用会出现在任务栏或者 Dock 里面,虽然 Linux 桌面现在没有名义上的任务栏或 Dock 的统一实现,但是关闭应用后台运行,然后通过系统托盘再唤出,这算是非常常见的行为了吧?虽然新出的任务栏协议 wlr-foreign-toplevel-management-unstable-v1 已经被 Waybar 支持了,但是我用的 Quickshell 还没有支持。另外早在 X11 时代,就有众多的例如 XEmbed 和 StatusNotifierItem 协议来实现系统托盘功能了,虽然万恶的 GNOME 并不喜欢,但是这并不是 Wayland 不考虑的原因。
由于以上原因,winit 系列的窗口管理在实现这一部分的时候选择了原教旨主义
fn set_visible(&self, _visible: bool) {
// Not possible on Wayland.
}
不过我并不信 Wayland 教,所以我准备提出修正主义。平时在 Linux 上使用软件的时候,观察到比如 QQ, Telegram 这种软件,它们是可以关闭后再从系统托盘里打开的,所以我们作为好用的播放器,也需要做到这样的使用体验。既然 Chromium 能实现,那么就说明这在技术上是可以实现的,我们只需要照抄它的实现就好了。
首次尝试解决办法
其实我早就知道 Wayland 没有实现最小化了,并且我在发布到 GitHub 之前就自己尝试修复了,那么我如何抄袭 Chromium 来实现最小化呢?
- 我修改了 winit, 让他支持了 Wayland 下的
set_visible(false),这样就可以做到关闭窗口了,此处surface: window.wl_surface().clone(),提交了空 buffer, 这样 compositor 就不会显示应用画面了。surface.attach(None, 0, 0); surface.commit(); - 在要显示窗口的时候,重置帧回调状态,然后
surface.commit(),再request_redraw(),希望应用重新画一帧,把 buffer attach 回去,这样就能继续显示窗口了。let mut state = self.window_state.lock().unwrap(); state.set_visible(true); // Reset frame callback state to break the deadlock. // When hidden, compositor stops sending frame callbacks, leaving state as Requested. // We need to reset it so the event loop will dispatch RedrawRequested. state.frame_callback_reset(); // Commit to signal the compositor. surface.commit(); // Ask the application to redraw so that a buffer is re-attached. self.request_redraw();request_redraw的实现如下:pub fn request_redraw(&self) { if !self.window_state.lock().unwrap().visible() { return; } if self .window_requests .redraw_requested .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) .is_ok() { self.event_loop_awakener.ping(); } } - 事件循环不给隐藏窗口分发 RedrawRequested,即隐藏时不再允许
request_redraw()。
这看起来就是对的,也就是把 set_visible(false) 解释成了 “unmap wl_surface”,把 set_visible(true) 解释成了 “commit 一个空 wl_surface 触发 remap,然后请求 redraw 重新
attach buffer”。这里注意:我们 unmap 的,attach 的,commit 的都是 wl_surface。
事实上,在 Hyprland 上,这样确实是对的,因为我用的就是 Hyprland, 并且我自己用了很久都没有问题,那为什么在 Niri 上就要协议报错呢?
深入 Wayland 协议
为了搞清楚 Wayland 到底如何看待应用尝试最小化和还原,以及 Chromium 是如何在 Niri 和 Hyprland 上都实现效果相同的最小化,我决定从协议层面和源码研究。观察到,在 xdg_surface.configure 协议中写道2:
<event name="configure">
<description summary="suggest a surface change">
The configure event marks the end of a configure sequence. A configure
sequence is a set of one or more events configuring the state of the
xdg_surface, including the final xdg_surface.configure event.
Where applicable, xdg_surface surface roles will during a configure
sequence extend this event as a latched state sent as events before the
xdg_surface.configure event. Such events should be considered to make up
a set of atomically applied configuration states, where the
xdg_surface.configure commits the accumulated state.
Clients should arrange their surface for the new states, and then send
an ack_configure request with the serial sent in this configure event at
some point before committing the new surface.
If the client receives multiple configure events before it can respond
to one, it is free to discard all but the last event it received.
</description>
<arg name="serial" type="uint" summary="serial of the configure event"/>
</event>
<interface name="xdg_toplevel" version="7">
<description summary="toplevel surface">
This interface defines an xdg_surface role which allows a surface to,
among other things, set window-like properties such as maximize,
fullscreen, and minimize, set application-specific metadata like title
and id, and well as trigger user interactive operations such as
interactive resize and move.
A xdg_toplevel by default is responsible for providing the full intended
visual representation of the toplevel, which depending on the window
state, may mean things like a title bar, window controls and drop shadow.
Unmapping an xdg_toplevel means that the surface cannot be shown
by the compositor until it is explicitly mapped again.
All active operations (e.g., move, resize) are canceled and all
attributes (e.g. title, state, stacking, ...) are discarded for
an xdg_toplevel surface when it is unmapped. The xdg_toplevel returns to
the state it had right after xdg_surface.get_toplevel. The client
can re-map the toplevel by performing a commit without any buffer
attached, waiting for a configure event and handling it as usual (see
xdg_surface description).
Attaching a null buffer to a toplevel unmaps the surface.
</description>
...
...
</interface>
上面两段的大概意思是说 Wayland 要求创建窗口的流程符合如下顺序:

根据协议报错,我应该是在接受 configure 之前就 attach 了 buffer, 事实也确实如此,根据我们上面的代码,我们在 commit 了新的 wl_surface 之后直接请求了 redraw, 在 redraw 过程中 attach 了新的 buffer, 按照 Wayland 协议,这样做顺序不对,Niri 也完成了对这个顺序的校验,如果顺序不对,那么就会抛出协议报错。但目前看来,Hyprland 看上去是没有校验这个顺序的。
第二次尝试解决方法
我们知道了 Wayland 标准是如何要求我们按照流程进行最小化的,于是便可以着手修复这个问题了,这次我在 remap 之后添加了等待 compositor 发送 configure 的逻辑,并在 ack 之后再进行请求重绘,这次在 Niri 上也能非常丝滑地最小化和复原了。但是事情真的有这么顺利吗?如果到此为止,这不过是一次简单的协议利用开发经历,跨平台适配 Wayland 案例罢了。然而在这个时候,我发现 Hyprland 竟然不能复原窗口了!
我明明是按照 Wayland 协议来完整实现了 unmap 和 remap 的流程了啊,为什么一开始能用的 Hyprland 反而不能用了呢?就连严格实现流程校验的 Niri 都能正常使用了,Hyprland 反而不能使用了,就简单加了一个等待 configure 的逻辑,难道说——Hyprland 其实不是 Wayland 的标准实现?
不同 Compositor 实现问题
Hyprland 对于 wl_surface 的 unmap 行为的处理
同样代码的情况下,Niri 和 Hyprland 行为不同,并且 Hyprland 很有可能是卡在了等待 configure 这一步上,那么我们应该细看 Hyprland 对于这个行为是怎么处理的,见 Hyprland/src/protocols/XDGShell.cpp 第 421 行3:
if UNLIKELY (m_initialCommit && m_surface->m_pending.buffer) {
m_resource->error(-1, "Buffer attached before initial commit");
return;
}
if (m_surface->m_current.texture && !m_mapped) {
// this forces apps to not draw CSD.
if (m_toplevel)
m_toplevel->setMaximized(true);
m_mapped = true;
m_surface->map();
m_events.map.emit();
return;
}
if (!m_surface->m_current.texture && m_mapped) {
m_mapped = false;
m_events.unmap.emit();
m_surface->unmap();
return;
}
m_events.commit.emit();
m_initialCommit = false;
那么 Hyprland 是如何发送 configure 的呢,见 Hyprland/src/protocols/XDGShell.cpp 第 538 行3:
uint32_t CXDGSurfaceResource::scheduleConfigure() {
if (m_configureSource)
return m_scheduledSerial;
m_configureSource = wl_event_loop_add_idle(g_pCompositor->m_wlEventLoop, onConfigure, this);
m_scheduledSerial = wl_display_next_serial(g_pCompositor->m_wlDisplay);
return m_scheduledSerial;
}
void CXDGSurfaceResource::configure() {
m_configureSource = nullptr;
m_resource->sendConfigure(m_scheduledSerial);
}
那么 Hyprland 是多久调用 scheduleConfigure() 的呢,见 Hyprland/src/desktop/view/Window.cpp 第 2318 行4:
void CWindow::commitWindow() {
if (!m_isX11 && m_xdgSurface->m_initialCommit) {
// try to calculate static rules already for any floats
m_ruleApplicator->readStaticRules(true);
const Vector2D predSize = !m_ruleApplicator->static_.floating.value_or(false) // no float rule
&& !m_isFloating // not floating
&& !parent() // no parents
&& !g_pXWaylandManager->shouldBeFloated(m_self.lock(), true) // should not be floated
?
g_layoutManager->predictSizeForNewTiledTarget().value_or(Vector2D{}) :
Vector2D{};
Log::logger->log(Log::DEBUG, "Layout predicts size {} for {}", predSize, m_self.lock());
m_xdgSurface->m_toplevel->setSize(predSize);
return;
}
...
}
可见,Hyprland 的实现中,m_initialCommit 是调用 scheduleConfigure() 的必要条件,而 m_initialCommit 是一个 m_xdgSurface 的属性,同一个 xdg_surface 一旦完成过第一次 initial commit/configure,它的 m_initialCommit 就永久变成 false, unmap 只会把它标成 unmapped,不会把它送回“需要新的 initial configure”状态。但是我们当前的修复并没有销毁 xdg_surface,而是只对 wl_surface 进行了 unmap, 除非我们按照 Wayland 协议使用 xdg_toplevel API 调用 setSize() 之类的函数,否则 Hyprland 再也不会发送 configure 给我们。
Niri 对于 wl_surface 的 unmap 行为的处理
Hyprland 将认为 wl_surface 的 unmap 和是否需要 initialCommit 的 configure 是无关的,那 Niri 呢?Niri 非常直接明了地说明了:新 unmap 的窗口必须重新初始化。见 niri/src/handlers/compositor.rs 第 311 行5:
// Newly-unmapped toplevels must perform the initial commit-configure sequence
// afresh.
let unmapped = Unmapped::new(window);
self.niri.unmapped_windows.insert(surface.clone(), unmapped);
而且 Niri 专门为 unmap 这个行为写了处理逻辑,见 niri/src/window/unmapped.rs 第 80 行6:
impl Unmapped {
/// Wraps a newly created window that hasn't been initially configured yet.
pub fn new(window: Window) -> Self {
Self {
window,
state: InitialConfigureState::NotConfigured {
wants_fullscreen: None,
wants_maximized: false,
},
activation_token_data: None,
}
}
pub fn needs_initial_configure(&self) -> bool {
matches!(self.state, InitialConfigureState::NotConfigured { .. })
}
pub fn toplevel(&self) -> &ToplevelSurface {
self.window.toplevel().expect("no X11 support")
}
}
之后,如果这个已经 unmapped 的 surface 又 commit 了,Niri 会检查,见 niri/src/handlers/compositor.rs 第 255 行5:
if unmapped.needs_initial_configure() {
let toplevel = unmapped.window.toplevel().expect("no x11 support").clone();
self.queue_initial_configure(toplevel);
}
排队结束后正式发送,见 niri/src/handlers/xdg_shell.rs 第 1192 行7:
if let Some(unmapped) = state.niri.unmapped_windows.get(toplevel.wl_surface()) {
if unmapped.needs_initial_configure() {
state.send_initial_configure(&toplevel);
}
}
对应的,Hyprland 在得知一个 wl_surface 被 unmap 之后会干什么呢?
if (m_surface->m_current.texture && !m_mapped) {
m_mapped = true;
m_surface->map();
m_events.map.emit();
return;
}
if (!m_surface->m_current.texture && m_mapped) {
m_mapped = false;
m_events.unmap.emit();
m_surface->unmap();
return;
}
答案是什么都不干,单纯标记它一下。
最终解决方法
既然知道了不同 Compositor 对 Wayland 协议的实现不同,尤其是对 configure 的发送时机有着不一样的理解,那我们最终的解决方法就是找他们共同的,会发送 configure 的时机——重建 xdg_surface 的时候。我们之前只是单纯 unmap 了 wl_surface,虽然 Niri 能正确地处理它,但是 Hyprland 并不觉得需要再次 initial Commit 和 Configure, 而是可以直接 attach buffer。所以我们为了多平台表现一致,决定重新实现我们的最小化步骤,而不是在恢复的时候等待不同 Compositor 给我们可能永远不会存在的 Configure。
在实现过程中,发现旧版 smithay-client-toolkit 在实现上把 wl_surface 包装成 xdg_surface + xdg_toplevel,并把它们存进 WindowInner;只有 WindowInner 被 drop 时,才会按协议销毁这些对象,也就是说必须要让 wl_surface 给 xdg_surface 和 xdg_toplevel 陪葬。但是如果直接 drop wl_surface, 恢复时重建会消耗大量资源,导致窗口出现速度非常慢,这不是我们想看到的。
所以我重新设计了 smithay-client-toolkit 的设计,方便单独销毁 xdg_surface 和 xdg_toplevel,并给 winit 加入了更多的状态:
pub struct Surface {
wl_surface: wl_surface::WlSurface,
owns_wl_surface: bool,
}
impl Surface {
pub fn adopt(surface: wl_surface::WlSurface) -> Self {
Self { wl_surface: surface, owns_wl_surface: false }
}
}
impl Clone for Surface {
fn clone(&self) -> Self {
Self { wl_surface: self.wl_surface.clone(), owns_wl_surface: false }
}
}
impl Drop for Surface {
fn drop(&mut self) {
if self.owns_wl_surface {
self.wl_surface.destroy();
}
}
}
这样就可以获得一个 wl_surface 的 clone, 然后可以在 winit 里进行重新创建 xdg_surface 和 xdg_toplevel:
fn hide_role(&mut self) {
self.visible = false;
self.pending_show_configure = false;
self.frame_callback_state = FrameCallbackState::None;
let window = self.window.take();
drop(window);
self.frame = None;
}
fn show_role(&mut self) {
self.visible = true;
self.pending_show_configure = true;
self.frame_callback_state = FrameCallbackState::None;
self.frame = None;
let window = self.xdg_shell.create_window(
self.surface.clone(),
self.requested_window_decorations(),
&self.queue_handle,
);
self.window = Some(window);
self.replay_role_state();
}
这样就可以完美自己控制 wl_surface 的生命周期并能让所有的 Compositor 都发送 Configure 了。