从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,也就是读取到消息将消息添加到队列中的时候,检查 opcode 和 wl_interface 的 event_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,只能说单就这个问题本身而言代价太大了,不易推动。
保证协议通信兼容性的方案
要解决上述问题,笔者认为目前有三种办法:
- 改进 libwayland,让其支持运行时动态检测协议通信兼容性,客户端在 bind 的时候已经指定了想要使用的协议版本,但是 libwayland 并没有充分利用这个版本号。
- 每次 libwayland 升级时,所有应用均升级到相同的协议版本。这显然工作量很大,可以通过添加空 handler 减少工作量,但同样为后期维护带来了相当大的复杂度。
- 每一个使用 wayland.xml 生成代码的应用,都自己维护一个版本的 xml,也就是表格中的自分发方式。Qt 项目实际上就是使用这种方式。系统中直接接触到 wayland.xml 的应用并不多,可能只有 GTK 和 Qt 这种底层组件需要做这种工作,但是这种做法加剧了 wayland 生态的碎片化,从工程领域也不推荐。
方法一可以从根本上解决问题,wayland 上游目前并没有相关的讨论,或许笔者应该在上游先提一个 issue。
结语
综合上述的报告和分析,在开发者最常处于的场景(使用系统 wayland 库提供的接口开发)下,wayland 协议的兼容性实际上是没有保证的。在目前的开发工作中,笔者认为大家还是应该注意到这个问题,在开发的时候要注意自己是否实现了系统 wayland 仓库提供的接口中的所有 handler。应用也可以在编译时启用部分初始化结构体的编译警告来避免 wl_xxx_interface 结构体部分初始化。比如,在gcc中,你可以指定 -Wmissing-field-initializers 开启该编译警告检查,并使用 -Werror 将编译警告视为错误。在全局预防方面,或许合成器应该担起大任,在发送事件之前进行版本检查?也许 wayland 在发展初期就将这份责任交给了上层合成器。