Webkit在嵌入式设备渲染卡死现象的分析
零、引言
所有乍见诡异、无法理解的现象背后,都有一系列我们尚未了解的细节,以及一个合理自洽的解释。
一、问题背景
我们的项目之前使用的上屏方式是基于LVGL改进的一套DRM封装,支持3缓冲,性能尚可,但通过对WPEWebkit的调研,发现其有两套API:
- WPE 1.1(通过
ENABLE_WPE_1_1_API控制构建):支持Headless / Wayland - WPE 2.0 (通过
ENABLE_WPE_PLATFORM控制构建):支持Headless / Wayland / DRM
于是,借着升级Webkit(2.46.2 -> 2.50.0)的机会,我们也决定使用WPE2.0,而不是自研的DRM上屏,以获取更多先进的功能,摒弃一些历史包袱。
升级之后,一个明显的好处就是:同一份代码,可以运行在不同的桌面环境(如下图):
- 启动weston,再启动项目,此时是基于Wayland协议上屏
- 关闭weston,设置环境变量
export WPE_DISPLAY="wpe-display-drm",再启动项目,此时是直接基于DRM接口上屏

测试URL: https://support.ekioh.com/benchmarks/balls.html
但实测发现:使用DRM上屏时,渲染900多帧后必现卡死,而基于Wayland上屏不会出现这个问题。
另外,我们编译官方的WPEWebkit也能复现这个问题(这就排除了我们项目自身的影响):
- 2.50.0 Sat Sep 20 2025(这是我们项目的基线版本,能复现问题)
- 2.51.3 Tue Dec 09 2025(这是当前最新的wpewebkit版本,也能复现问题)
版本信息:wpewebkit-Releases。(网页内容随时间变化,当前快照如下)

基于以上过程,可以认为这个Bug是官方引入且暂时没有解决的。
二、排查过程
1. 从DisplayLink开始
Webkit是多进程的,对于一个网页:
- UI进程负责上屏相关工作
- Web进程负责渲染相关工作
渲染与上屏是一个Loop,循环往复,无论从哪个环节切入排查,都将遍历所有的流程,所以我们从比较熟悉的ThreadedCompositor::renderLayerTree开始排查,其是渲染的核心,而DisplayLink则是对Web进程渲染全流程的概括,其本质是VSync,之所以如此命名,个人猜测可能是Mac平台先用DisplayLink来表示这个概念,但其本质和Vsync / VBlank是相似的。
// Source/WTF/wtf/PlatformHave.h
#if PLATFORM(MAC) || PLATFORM(GTK) || PLATFORM(WPE)
#define HAVE_DISPLAY_LINK 1
#endif
DisplayLink的逻辑核心就是接收UI进程的VSync信号,并有自己的一套阈值逻辑,来保证60fps,其关键代码如下图

具体的细节不展开讨论,由于其每个环节有不少检查,是有可能导致卡死的。如:
// Source/WebKit/WebProcess/WebPage/CoordinatedGraphics/ThreadedCompositor.cpp
void ThreadedCompositor::frameComplete()
{
WTFEmitSignpost(this, FrameComplete);
ASSERT(m_compositingRunLoop->isCurrent());
#if !HAVE(DISPLAY_LINK)
displayUpdateFired();
sceneUpdateFinished();
#else
Locker stateLocker { m_compositingRunLoop->stateLock() };
m_compositingRunLoop->updateCompleted(stateLocker);
#endif
}
//Source/WebKit/WebProcess/WebPage/CoordinatedGraphics/CompositingRunLoop.cpp
void CompositingRunLoop::updateCompleted(Locker<Lock>& stateLocker)
{
// strip some code....
switch (m_state.update) {
case UpdateState::Idle:
case UpdateState::Scheduled:
return;
case UpdateState::InProgress:
if (m_state.pendingUpdate) {
m_state.pendingUpdate = false;
m_state.update = UpdateState::Scheduled;
if (!m_state.isSuspended)
m_updateTimer.startOneShot(0_s);
return;
}
m_state.update = UpdateState::Idle;
return;
}
}
以上代码便是DisplayLink中的一环,渲染结束、上屏完成后,必需调用frameComplete通知,状态更新后才开始处理新的任务,这样可以避免帧的积压。
实际上,DRM模式卡死时,这个frameComplete确实没有调用,进而导致整个DisplayLink的循环中断了。
那么,看起来只要解决frameComplete没有调用的问题,这个问题就解决了?
2. 误解frameDone的来源,浪费时间
frameComplete是由AcceleratedSurface::frameDone触发的,其调用点其实有两个:
// 调用点1:向libwpe.so注册,监听回调,这个过程是在Web进程的
// Source/WebKit/WebProcess/WebPage/CoordinatedGraphics/AcceleratedSurface.cpp
static struct wpe_renderer_backend_egl_target_client s_client = {
// frame_complete
[](void* data) {
auto& surface = *reinterpret_cast<AcceleratedSurface*>(data);
surface.frameDone();
},
// padding
nullptr,
nullptr,
nullptr,
nullptr
};
wpe_renderer_backend_egl_target_set_client(m_backend, &s_client, const_cast<AcceleratedSurface*>(&surface));
// 调用点2:通过IPC由UI进程调用
// build-aarch64-WPE-Debug/DerivedSources/WebKit/AcceleratedSurfaceMessageReceiver.cpp
void AcceleratedSurface::didReceiveMessage(IPC::Connection& connection, IPC::Decoder& decoder)
{
Ref protectedThis { *this };
if (decoder.messageName() == Messages::AcceleratedSurface::ReleaseBuffer::name())
return IPC::handleMessage<Messages::AcceleratedSurface::ReleaseBuffer>(connection, decoder, this, &AcceleratedSurface::releaseBuffer);
if (decoder.messageName() == Messages::AcceleratedSurface::FrameDone::name())
return IPC::handleMessage<Messages::AcceleratedSurface::FrameDone>(connection, decoder, this, &AcceleratedSurface::frameDone);
UNUSED_PARAM(connection);
RELEASE_LOG_ERROR(IPC, "Unhandled message %s to %" PRIu64, IPC::description(decoder.messageName()).characters(), decoder.destinationID());
decoder.markInvalid();
}
由于我们是基于WPEWebkit,编译的PORT也是WPE,自然容易认为调用点是1,由此得出的结论是:libwpe.so的frame_complete回调失效了。
为此,笔者尝试编译了一个WebPlatformForEmbedded/libwpe以排查问题,担发现相关日志并未触发。最终调试证实,真正的调用点是2。
为了理清这个问题,有必要理清这两个调用点的使用条件,以Linux平台为例,相关代码如下:
// Source/WebCore/platform/graphics/PlatformDisplay.h
enum class Type {
#if PLATFORM(WIN)
Windows, // 不编译
#endif
#if USE(WPE_RENDERER)
WPE, // 0
#endif
Surfaceless, // 1
#if USE(GBM)
GBM, // 2
#endif
#if PLATFORM(GTK)
Default, // 3
#endif
};
// Source/WebKit/WebProcess/WebPage/CoordinatedGraphics/AcceleratedSurface.cpp
AcceleratedSurface::SwapChain::SwapChain(uint64_t surfaceID)
: m_surfaceID(surfaceID)
{
auto& display = PlatformDisplay::sharedDisplay();
switch (display.type()) {
#if PLATFORM(GTK) || ENABLE(WPE_PLATFORM)
case PlatformDisplay::Type::Surfaceless:
if (display.eglExtensions().MESA_image_dma_buf_export && WebProcess::singleton().rendererBufferTransportMode().contains(RendererBufferTransportMode::Hardware))
m_type = Type::Texture;
else
m_type = Type::SharedMemory;
break;
#if USE(GBM)
case PlatformDisplay::Type::GBM:
if (display.eglExtensions().EXT_image_dma_buf_import)
m_type = Type::EGLImage;
else
m_type = Type::SharedMemory;
break;
#endif
#endif
#if USE(WPE_RENDERER)
case PlatformDisplay::Type::WPE:
m_type = Type::WPEBackend;
break;
#endif
default:
break;
}
}
影响frameDone调用来源的因素其实是wpe api的选择。
- 使用wpe 1.1,
display.type()是Type::WPE,对应SwapChain::Type::WPEBackend - 使用wpe 2.0,
display.type()是Type::GBM,(通常)对应SwapChain::Type::EGLImage
wpe1.1 的核心是依赖libwpe.so, 而wpe2.0则抛弃了这个库,重新封装了DRM和Wayland的调用,主要代码在Source/WebKit/WPEPlatform目录.
在wpewebkit-2.46.2和wpewebkit-2.50.0的Tools/MiniBrowser/wpe/main.cpp中,对开启wpe2.0的参数设计也有变化,如下:
// wpewebkit-2.46.2
static const GOptionEntry commandLineOptions[] =
{
// strip some code..........
#if ENABLE_WPE_PLATFORM
{ "use-wpe-platform-api", 0, 0, G_OPTION_ARG_NONE, &useWPEPlatformAPI, "Use the WPE platform API", nullptr },
// strip some code..........
};
// wpewebkit-2.50.0
static const GOptionEntry commandLineOptions[] =
{
// strip some code..........
#if ENABLE_WPE_PLATFORM
{ "use-legacy-api", 0, 0, G_OPTION_ARG_NONE, &useLegacyAPI, "Use the WPE legacy API (libwpe)", nullptr },
#endif
// strip some code..........
};
也就是说,在2.46,wpe2.0(use-wpe-platform-api)还是默认关闭的,默认使用wpe1.1,而2.50后,wpe2.0是默认开启的(前提是编译开启了),使用wpe1.1需要显式声明。
这种细小的改变也说明了WPEWebkit的发展方向,当然,也要承认Webkit在WPE的基础上衍生出好几个相关的宏WPE \ WPE_PLATFORM \ WPE_RENDERER造成了理解上的负担,如果不建立全局的理解,就容易像笔者这样误入歧途,浪费时间。
3. 从frameDone切入IPC
至此,正确的排查方向是:为什么UI进程不发送frameDone消息了?
// Source/WebKit/WebProcess/WebPage/CoordinatedGraphics/AcceleratedSurface.cpp
void AcceleratedSurface::RenderTargetShareableBuffer::didRenderFrame(Vector<IntRect, 1>&& damageRects)
{
WebProcess::singleton().parentProcessConnection()->send(Messages::AcceleratedBackingStore::Frame(m_id, WTFMove(damageRects), WTFMove(m_renderingFenceFD)), m_surfaceID);
}
// Source/WeobKit/UIProcess/gtk/AcceleratedBackingStore.cpp
void AcceleratedBackingStore::bufferReleased(WPEBuffer* buffer)
{
if (auto id = m_bufferIDs.get(buffer)) {
UnixFileDescriptor releaseFence;
if (WPE_IS_BUFFER_DMA_BUF(buffer))
releaseFence = UnixFileDescriptor { wpe_buffer_dma_buf_take_release_fence(WPE_BUFFER_DMA_BUF(buffer)), UnixFileDescriptor::Adopt };
if (RefPtr legacyMainFrameProcess = m_legacyMainFrameProcess.get())
legacyMainFrameProcess->send(Messages::AcceleratedSurface::ReleaseBuffer(id, WTFMove(releaseFence)), m_surfaceID);
}
}
void AcceleratedBackingStore::frameDone()
{
if (RefPtr legacyMainFrameProcess = m_legacyMainFrameProcess.get())
legacyMainFrameProcess->send(Messages::AcceleratedSurface::FrameDone(), m_surfaceID);
}
这里的排查和调试细节略去,过于琐碎,核心在于这个流程:
- 一帧渲染完成,Web进程发送AcceleratedBackingStore::Frame消息,通知UI进程上屏
- UI进程对于已经完成上屏的Buffer,通知Web进程对应的缓冲区(ReleaseBuffer)可用于渲染(可选)
- UI进程对于已经上屏的帧,通知Web进程,发送AcceleratedSurface::FrameDone消息
实际排查发现,frameDone没有按预期发送的原因其实是这一帧的AcceleratedBackingStore::Frame(Web->UI)消息没有接收到,由于前置逻辑没有执行,frameDone自然没有执行。
继续分析,发现卡死前的IPC失败点如下:
// Source/WebKit/Platform/IPC/unix/ConnectionUnix.cpp
static ssize_t readBytesFromSocket(int socketDescriptor, Vector<uint8_t>& buffer, Vector<int>& fileDescriptors)
{
// strip some code ....
while (true) {
ssize_t bytesRead = recvmsg(socketDescriptor, &message, MSG_NOSIGNAL);
if (bytesRead < 0) {
// strip some code ....
}
// 失败在这个if分支
if (message.msg_flags & MSG_CTRUNC) {
// Control data has been discarded, which is expected by processMessage(), so consider this a read failure.
buffer.shrink(previousBufferSize);
return -1;
}
struct cmsghdr* controlMessage;
// strip some code ....
}
return -1;
}
要继续后文的分析,要对Linux的IPC机制和fd(文件描述符)的传递有个基本理解,在此不展开,直接说重点:
- 发送AcceleratedBackingStore::Frame时,是会带一个fd信息(即
m_renderingFenceFD)的,用于实现GPU侧的Fence Sync - 这个fd通过Socket IPC传递时是放在
msghdr->msg_control字段的,而不是作为普通的数据传递 - 卡死前的那一次IPC处理时,这个消息触发了上面代码的
message.msg_flags & MSG_CTRUNC判断,进而被丢弃,导致UI侧认为没有新的帧到来,而Web侧认为当前帧没有完成上屏,从而形成卡死!!!
接下来,就是分析为什么900多帧后的这次IPC会触发这个MSG_CTRUNC,更诡异的是,为什么Wayland模式下就不会失败,而切换成DRM模式就必然失败?按理IPC和上屏模式是完全无关的。
4. 从IPC切入fd泄漏
事后看,这个Bug和IPC其实完全没有关系,但站在这个时间节点,我们能看到的、明确有错误的地方就是:IPC消息读取失败、msg_control字段被截断。
笔者一开始也是基于这个错误,把全部精力都放在了IPC的实现细节上,自然是没有什么进展。
但随着对整个渲染上屏逻辑的理解,并在怀疑的地方尽可能加上日志,终于找到了一些线索。
具体工作非常依赖灵光一现,这里只讲述最终成功的那个尝试路径:
- 比较
WPEViewWayland.cpp和WPEViewDRM.cpp的实现,发现wpe_buffer_dma_buf_set_release_fence只有在Wayland模式下才被调用,在DRM模式下,UI进程是不会通知Web进程这个消息的,于是尝试让DRM模式下也增加这个逻辑 - 修改代码后,发现UI->Web的IPC消息的fd字段的值并不像Wayland模式下那么稳定,而是递增的
为什么会递增呢?这是因为fd通过IPC传递之前,为了防止失败,都会复制一份,如下:
// Source/WTF/wtf/unix/UniStdExtrasUnix.cpp
int dupCloseOnExec(int fileDescriptor)
{
int duplicatedFileDescriptor = -1;
#ifdef F_DUPFD_CLOEXEC
while ((duplicatedFileDescriptor = fcntl(fileDescriptor, F_DUPFD_CLOEXEC, 0)) == -1 && errno == EINTR) { }
if (duplicatedFileDescriptor != -1) {
return duplicatedFileDescriptor;
}
#endif
// strip some code....
return duplicatedFileDescriptor;
}
正常情况下,fcntl会返回当前可用的最小fd数值,但现在每次都会增大1。笔者一开始认为是IPC时创建的fd没有正常回收,但close(fd)的返回值又确实是0,说明fd的close是成功的。
就在没有思路时,一条新的错误日志引起了笔者的注意:
Failed to render buffer: failed to commit properties:Bad file Descriptor.
其对应的逻辑如下:
// Source/WebKit/WPEPlatform/wpe/drm/WPEViewDRM.cpp
static bool wpeViewDRMCommitAtomic(WPEViewDRM* view, WPE::DRM::Buffer* buffer, std::optional<uint32_t> damageID, GError** error)
{
// strip some code ....
auto fd = gbm_device_get_fd(wpe_display_drm_get_device(display));
// strip some code ....
WTFLogAlways("drmModeAtomicCommit: %d", fd); // 发现这个fd是递增的
if (drmModeAtomicCommit(fd, request.get(), flags, view)) {
g_set_error(error, WPE_VIEW_ERROR, WPE_VIEW_ERROR_RENDER_FAILED, "Failed to render buffer: failed to commit properties: %s", strerror(errno));
return false;
}
// 此处应该close(fd),但没有
return true;
}
通过日志,发现这个fd确实是递增的,说明泄漏其实来源于此,而非笔者增改的逻辑,但恰恰是笔者增改的逻辑让fd泄漏浮出水面!!!
另外,通过/proc/pid/fd也容易发现,如果是IPC的dup - fd泄漏,则泄漏fd指向的应该是anon_inode:sync_file,而实际指向的是/dev/dri/card0:
root@rk3562-buildroot:/# ls -la /proc/4528/fd
......
lrwx------ 1 root root 64 Dec 15 19:38 90 -> /dev/dri/card0
.......
lrwx------ 1 root root 64 Dec 15 19:38 96 -> /dev/dri/card0
lrwx------ 1 root root 64 Dec 15 19:38 97 -> /dev/dri/card0
lrwx------ 1 root root 64 Dec 15 19:38 98 -> /dev/dri/card0
lrwx------ 1 root root 64 Dec 15 19:38 99 -> /dev/dri/card0
在上面wpeViewDRMCommitAtomic结束前,加上clsoe(fd),卡死的问题果然消失了!!至此,原因水落石出
但有一个问题,笔者觉得有必要明确:既然是fd的泄漏,为什么Bad file Descriptor.的报错一开始没有,而必需在笔者的一番修改之下才出现在日志呢?
个人猜测是:UI进程的fd是泄漏的,UI进程每接收Web进程的frame消息时,由于有fd数据,所以需要分配一个fd,但IPC的recvmsg逻辑比gbm_device_get_fd 更先触发这个Bug,因此一开始的报错在MSG_CTRUNC,而笔者修改的逻辑会导致UI进程消耗fd的速度增加,进而导致gbm_device_get_fd先触发这个Bug,从而打出了关键日志!!
为了验证这个猜想,我们重置所有代码,只修改一处:对gbm_device_get_fd多次调用,如下:
// Source/WebKit/WPEPlatform/wpe/drm/WPEViewDRM.cpp
auto fd = gbm_device_get_fd(wpe_display_drm_get_device(display));
fd = gbm_device_get_fd(wpe_display_drm_get_device(display));
fd = gbm_device_get_fd(wpe_display_drm_get_device(display));
这样fd在这个环节耗尽的概率就增大,实现测试时,如此修改后每次都会打印出这个日志:

至此,所有的疑问和现象都自洽了。但还有一些问题值得深思。
5. 对比官方代码的迭代
实际上,对于此类问题,笔者好奇的是:难道官方就没有人发现吗?可能有两个原因:
- 生产环境还是Wayland模式用的多?
- 对于wpe2.0 DRM模式,没有充分测试?
其实,在排查的过程中,笔者也尝试了官方的最新代码,发现wpe2.0 + drm相关的代码一直在改动,比如,笔者在排查过程中发现DRM模式并没有处理releaseFenceFD的问题,官方的代码已经在做类似优化,和笔者的预期是一致的。

另外,官方最新的tag是wpewebkit-2.51.3,而WebKit/WebKit(不是WebPlatformForEmbedded/WPEWebKit)的main分支其实有对wpeViewDRMCommitAtomic的修改,返回前增了一个wpeScreenDRMDestroyDumbBufferIfNeeded调用(说明维护人员也注意到了这里的资源管理有缺陷?):
// Source/WebKit/WPEPlatform/wpe/drm/WPEScreenDRM.cpp
void wpeScreenDRMDestroyDumbBufferIfNeeded(WPEScreenDRM* screen, int fd)
{
auto* priv = screen->priv;
if (!priv->dumb.bufferID)
return;
drmModeRmFB(fd, priv->dumb.frameBufferID);
drmModeDestroyDumbBuffer(fd, priv->dumb.bufferID);
priv->dumb.bufferID = 0;
priv->dumb.frameBufferID = 0;
}
但依然没有加上fd的释放,故运行依然会卡死,而且上屏也是有问题的,如下:
(Webkit-main分支,commit:b08a6a752d5fe71e8b155bb2017626ed4c868ed9)
三、总结
本身是一个很低级、很小的Bug, 但由于卡死时,触发MSG_CTRUNC后是没有任何报错的,只是丢掉消息,所以定位到这个环节就花了不少时间。如果一开始有Bad file descriptor的日志,这个Bug可能半天就解决了
这个Bug的排查过程,在我解Bug的经历中,也是值得单开一文记录的,其他的如:
最后,这个Bug的排查过程也留下了不少有益的经验:
- 以后遇到传递fd时发生
MSG_CTRUNC,应该重点关注fd的泄漏 - 通过泄漏fd指向的path(可通过
readlink或/proc/pid/fd获取),可判断fd的泄漏来源 MSG_CTRUNC发生时,应该至少有一条Warn级别的日志- 渲染上屏的全链路,是一个涉及面广且特别精细的流程,借助排查这个Bug,也加深了理解,后续应该逐步建立全局的理解,避免出现问题时过于被动
2025-12-17