Webkit在嵌入式设备渲染卡死现象的分析

本文约 5700 字,阅读需 12 分钟。

零、引言

所有乍见诡异、无法理解的现象背后,都有一系列我们尚未了解的细节,以及一个合理自洽的解释。

一、问题背景

我们的项目之前使用的上屏方式是基于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的选择。

  1. 使用wpe 1.1,display.type()Type::WPE,对应SwapChain::Type::WPEBackend
  2. 使用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);
}

这里的排查和调试细节略去,过于琐碎,核心在于这个流程:

  1. 一帧渲染完成,Web进程发送AcceleratedBackingStore::Frame消息,通知UI进程上屏
  2. UI进程对于已经完成上屏的Buffer,通知Web进程对应的缓冲区(ReleaseBuffer)可用于渲染(可选)
  3. 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(文件描述符)的传递有个基本理解,在此不展开,直接说重点:

  1. 发送AcceleratedBackingStore::Frame时,是会带一个fd信息(即m_renderingFenceFD)的,用于实现GPU侧的Fence Sync
  2. 这个fd通过Socket IPC传递时是放在msghdr->msg_control字段的,而不是作为普通的数据传递
  3. 卡死前的那一次IPC处理时,这个消息触发了上面代码的message.msg_flags & MSG_CTRUNC判断,进而被丢弃,导致UI侧认为没有新的帧到来,而Web侧认为当前帧没有完成上屏,从而形成卡死!!!

接下来,就是分析为什么900多帧后的这次IPC会触发这个MSG_CTRUNC更诡异的是,为什么Wayland模式下就不会失败,而切换成DRM模式就必然失败?按理IPC和上屏模式是完全无关的

4. 从IPC切入fd泄漏

事后看,这个Bug和IPC其实完全没有关系,但站在这个时间节点,我们能看到的、明确有错误的地方就是:IPC消息读取失败、msg_control字段被截断

笔者一开始也是基于这个错误,把全部精力都放在了IPC的实现细节上,自然是没有什么进展。

但随着对整个渲染上屏逻辑的理解,并在怀疑的地方尽可能加上日志,终于找到了一些线索。

具体工作非常依赖灵光一现,这里只讲述最终成功的那个尝试路径:

  1. 比较WPEViewWayland.cppWPEViewDRM.cpp的实现,发现wpe_buffer_dma_buf_set_release_fence只有在Wayland模式下才被调用,在DRM模式下,UI进程是不会通知Web进程这个消息的,于是尝试让DRM模式下也增加这个逻辑
  2. 修改代码后,发现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. 对比官方代码的迭代

实际上,对于此类问题,笔者好奇的是:难道官方就没有人发现吗?可能有两个原因:

  1. 生产环境还是Wayland模式用的多?
  2. 对于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;
}

详见:[WPE] Launching MiniBrowser on DRM mode doesn’t pick VSync clock by dpino · Pull Request #49876 · WebKit/WebKit · GitHub

但依然没有加上fd的释放,故运行依然会卡死,而且上屏也是有问题的,如下:

(Webkit-main分支,commit:b08a6a752d5fe71e8b155bb2017626ed4c868ed9)

三、总结

本身是一个很低级、很小的Bug, 但由于卡死时,触发MSG_CTRUNC后是没有任何报错的,只是丢掉消息,所以定位到这个环节就花了不少时间。如果一开始有Bad file descriptor的日志,这个Bug可能半天就解决了

这个Bug的排查过程,在我解Bug的经历中,也是值得单开一文记录的,其他的如:

最后,这个Bug的排查过程也留下了不少有益的经验:

  1. 以后遇到传递fd时发生MSG_CTRUNC,应该重点关注fd的泄漏
  2. 通过泄漏fd指向的path(可通过readlink/proc/pid/fd获取),可判断fd的泄漏来源
  3. MSG_CTRUNC发生时,应该至少有一条Warn级别的日志
  4. 渲染上屏的全链路,是一个涉及面广且特别精细的流程,借助排查这个Bug,也加深了理解,后续应该逐步建立全局的理解,避免出现问题时过于被动

2025-12-17

总阅读量次。