Contents

从mpv闪退引出的wayland协议兼容性思考

引言

在 UOS 的最新 kwin_wayland 环境下,mpv 播放器在反复全屏和还原过程中会出现闪退的问题,笔者经过一番排查,最终确定根因是 kwin_wayland 向 mpv 发送了一条 mpv 无法处理的高版本版本的 wl_pointer 事件,导致客户端在 libwayland 中 abort 了。借此机会,笔者仔细研究了出现该问题的条件和 libwayland 的机制,本文记录一些笔者对于系统中 wayland 协议兼容性的思考。

场景兼容性分类分析

传统上,我们通常关注动态库的 api 兼容性和 abi 兼容性。没有保证 api 兼容性的动态库,应用将可能在新版本上出现编译问题,动态库应首先保证 api 兼容性,以确保新版本推出时,适配旧版本的应用可以正确进行重构建。没有保证 abi 兼容性的动态库,如果应用没有进行重构建,使用的abi和动态库真实abi不一致,运行时会出现问题,比如无法访问某一个数据成员,库实现方往往会采用一些隐藏数据成员的方法来实现 abi 兼容,如 C 语言编程中的结构体数据成员隐藏和 Qt 编程中的 d 指针都是此种方法。对于没有破坏 api 兼容性但是破坏了 abi 兼容性的库,应用程序也可以通过重编的方法重新保证 abi 一致(只是保持单次一致,兼容性问题仍然存在)。上述分类是针对于动态库的,静态库因为其每次都需要和应用程序一起重新编译,不存在所谓的 abi 兼容性。

笔者在本文要讨论的 wayland 协议,即 wayland.xml,它作为文本文件,在各个项目中共享,经过 wayland-scanner 扫描后生成代码,和应用一起编译,可以看作是一种标准的静态库分发过程。那么对于 wayland 协议,我们应该如何讨论它的兼容性呢?笔者认为,上述问题应分解为:我们应该在何种情况下讨论 wayland 协议的何种兼容性。

考虑到实际情况和 wayland 实际处理,应该考虑分发渠道和应用重构建状态的组合。应用可能通过自分发和系统共享两种分发方式获取到 wayland 协议,在系统 wayland 协议升级之后,它也会存在已重构建和未重构建这两种状态。即应用存在自分发已重构建、自分发未重构建、系统共享已重构建和系统共享未重构建这四种情况。对于兼容性层面,我们应该考虑 api 兼容性,以及客户端和合成器采用不同版本协议的通信兼容性。

本文只讨论由 libwayland 提供的 wayland 层面保证的兼容性,即应用对兼容性无感知 ,它只会对自己有感知的版本进行实现,并假定对端也是使用相同版本。笔者认为,该情况对于客户端是很常见的情形,对于合成器这种责任重大的应用则见仁见智。

场景兼容性报告

综上,本文讨论的情况可总结为如下表格:

分发形式 / 重构建状态API / 源码兼容性协议通信兼容性 (客户端 vs. 合成器)
自分发 wayland.xml (已重构建)
自分发 wayland.xml (未重构建)
系统共享 wayland.xml (已重构建)
系统共享 wayland.xml (未重构建)

从表格中可以看到,wayland 保证了大部分情况的两种兼容性,唯独没办法保证系统共享 wayland.xml 在已重构建情况的兼容性。

wayland.xml 中添加新协议的方式,保证了 wayland.xml 在所有情况下的源码兼容性。每次往 wayland.xml 中添加一个 event 或者一个新的 enum entry 的时候,审核者要求开发者将请求顺序添加在 interface 的最后面或者 enum 定义的最后面,这样通过 wayland-scanner 生成的代码中的数据结构的前半部分会兼容旧版本,同一含义的枚举值也保持不变,该行为在一定程度上也保证了协议通信兼容性。对于修改原有协议和删除原有请求或事件,这种行为是被禁止的,需要通过另外起草一个新版本协议的方式进行,这也能解释为什么 wayland 实现同一个功能的协议版本这么多,例如 text-input 协议已经迭代到 v4 了。

协议通信兼容性深入研究

对于协议通信兼容性,wayland.xml 中要求对每一个新加的事件或者请求添加一个 since 字段,说明事件或者请求添加的最低版本。但事实上,since 字段起到的作用十分有限,它只能防止客户端调用某一个合成器不支持的请求,但是并不能有效防止合成器向客户端发送一个高版本的事件消息。但是 libwayland 并不是完全没有对该情况进行检查,从 libwayland 的代码中可以看到:

static int
queue_event(struct wl_display *display, int len)
{
    ......

    if (opcode >= proxy->object.interface->event_count) {
        wl_log("interface '%s' has no event %u\n",
               proxy->object.interface->name, opcode);
        return -1;
    }

    ......
    return size;
}

libwayland 会在 queue_event,也就是读取到消息将消息添加到队列中的时候,检查 opcodewl_interfaceevent_count 的大小关系,从而达到防止接收到高版本事件的目的。为什么笔者说该防护是有限的呢?

因为 interface 的 event_count 可能并不是客户端预期的值。

讨论上表中唯一没有保证兼容性的情况,应用使用系统共享的 wayland.xml。

  • 在 v1 版本的时候,应用实现了 v1 版本定义的所有事件,合成器实现的也是 v1 版本,此时双方可以正常通信。
  • 当 wayland.xml 升级到 v2 版本,添加了一个事件,合成器跟进升级了 v2 版本,实现了这一个事件。
  • 客户端因为没有升级到 v2 版本的需求,仍然只实现 v1 版本,在系统提供的 wayland.xml 升级之后它进行了重构建。

因为事件添加在最后的特性,此次从 v1 升级到 v2 并不会破坏客户端的构建,客户端能够正常地构建,不过一旦运行,合成器往客户端发送 v2 版本的事件的时候,事件就会突破上述检查直至处理的时候找到 null 的 handler 而引发 abort,因为客户端在重构建后使用的 v2 版本的 wayland.xml,event_count 是通过 xml 生成,自然也是 v2 版本的 event_count。对于另外三种情况,应用使用的都是应用已完全实现版本的 wayland.xml 协议构建,event_count 是正确的,也就可以在上述代码中被拦截,保证了协议通信兼容性。

细心的读者可以发现,系统共享 wayland.xml 并且已重构建的情况,相当于应用使用某一版本的 wayland.xml 协议但是并没有将所有 handler 实现(哪怕 handler 不处理任何逻辑)。根据开发经验,这在 wayland 开发里面显然是一种错误的做法,当合成器发送客户端未实现的事件时,客户端就会直接终止。虽然这是一个在开发者看来明显缺乏审查和被忽视的场景,但对于整个系统维护来说,这是一个再正常不过的场景(系统中 wayland.xml 升级,应用在无感知的情况下重新构建),比如本文的引子 mpv 播放器。它是一个三方应用,系统开发者在升级 wayland.xml 的时候显然不会帮 mpv 升级 wayland 的实现,而只会确认它是否能在新版本的 wayland.xml 协议上编译通过并基本正常运行。或者退一步,系统管理员发现了 mpv 存在兼容性问题,那如何去对它进行修改也是一个问题。如若 mpv 上游社区是一个活跃的社区,那更新版本或许是一个轻松的解决方案,但 mpv 版本和 libmpv 版本绑定,深度影院依赖 libmpv,升级 mpv 意味着深度影院要重新适配新版的 libmpv。为了一个用户可能不常使用到的三方应用而重新适配新版 libmpv,只能说单就这个问题本身而言代价太大了,不易推动。

保证协议通信兼容性的方案

要解决上述问题,笔者认为目前有三种办法:

  1. 改进 libwayland,让其支持运行时动态检测协议通信兼容性,客户端在 bind 的时候已经指定了想要使用的协议版本,但是 libwayland 并没有充分利用这个版本号。
  2. 每次 libwayland 升级时,所有应用均升级到相同的协议版本。这显然工作量很大,可以通过添加空 handler 减少工作量,但同样为后期维护带来了相当大的复杂度。
  3. 每一个使用 wayland.xml 生成代码的应用,都自己维护一个版本的 xml,也就是表格中的自分发方式。Qt 项目实际上就是使用这种方式。系统中直接接触到 wayland.xml 的应用并不多,可能只有 GTK 和 Qt 这种底层组件需要做这种工作,但是这种做法加剧了 wayland 生态的碎片化,从工程领域也不推荐。

方法一可以从根本上解决问题,wayland 上游目前并没有相关的讨论,或许笔者应该在上游先提一个 issue。

结语

综合上述的报告和分析,在开发者最常处于的场景(使用系统 wayland 库提供的接口开发)下,wayland 协议的兼容性实际上是没有保证的。在目前的开发工作中,笔者认为大家还是应该注意到这个问题,在开发的时候要注意自己是否实现了系统 wayland 仓库提供的接口中的所有 handler。应用也可以在编译时启用部分初始化结构体的编译警告来避免 wl_xxx_interface 结构体部分初始化。比如,在gcc中,你可以指定 -Wmissing-field-initializers 开启该编译警告检查,并使用 -Werror 将编译警告视为错误。在全局预防方面,或许合成器应该担起大任,在发送事件之前进行版本检查?也许 wayland 在发展初期就将这份责任交给了上层合成器。