Stage 2: the frame never leaves the GPU
What was actually wrong with the readback
Stage 1's cost was structural, not incidental. read_to_image pulls a finished frame out of GPU memory into system memory; the red/blue swap walks every pixel on the CPU; the upload hands it back to the GPU. Three passes over the whole framebuffer, once a frame, to move data between two consumers that were already sitting on the same card.
No amount of tuning changes that shape. The only fix is to stop copying — to give GPUI a reference to the memory Servo already painted into.
On Linux that reference has a standard name: a dma-buf. A file descriptor that refers to a buffer of GPU memory, which another API can import without a copy. Both halves of rumb's stack can reach it: Servo renders through GL/EGL, GPUI's backend is wgpu over Vulkan, and each has an extension path to dma-buf. Stage 2 is the work of connecting those two paths and deleting everything in between.
The export side
The producer half lives in the Servo fork; rumb's tick() calls into it.
When Servo has a new frame, its WebViewDelegate fires notify_new_frame_ready and the shell flips a dirty flag. On the next GPUI frame tick() sees the flag, calls webview.paint(), and then asks the rendering context to export the framebuffer. That export does three things: it blits Servo's framebuffer into a private intermediate texture — a GPU-to-GPU copy, not a readback — exports that texture as a dma-buf through EGL_MESA_image_dma_buf_export, and creates a glFenceSync exported as a sync fd.
What comes back is a handle carrying the buffer fd plus everything needed to interpret it — fourcc, modifier, stride, offset, width, height — and the fence fd alongside. Both descriptors are ours to own and close. The handle goes into a slot shared with the GPUI side.
The slot is an Arc<Mutex<Option<DmaBufHandle>>>, and the choice is deliberate rather than lazy. The consumer trait requires Send + Sync; the access pattern is one writer per frame and N readers per frame, all on the same thread today, but the bound keeps the option open if GPUI ever dispatches drawing off-thread.
When the export returns nothing, the shell logs a warning and the frame reuses whatever handle is already in the slot — or stays blank, if there has never been one.
The import side
On the other end, ServoDmaBufHandle implements GPUI's ExternalGpuTexture. Three of its methods carry the design.
descriptor() locks the slot and dups the fd before handing it over, so GPUI owns its own copy and can close it when it is done with the texture it imported. The engine's cached handle stays exactly where it was — which is why a frame where Servo painted nothing still shows the last good frame instead of flickering to black.
size() reports the dimensions carried in the dma-buf itself rather than any separately tracked value, because the buffer is authoritative: it is what was exported on the last paint.
fence() is the odd one, and it gets its own section.
The shell side of main.rs lost its image element and gained external_image(texture_handle). That element emits an ExternalSprite scene primitive; GPUI's wgpu backend reads the descriptor in draw_external_sprites, imports the dma-buf as a wgpu::Texture through wgpu::hal::vulkan and ash, and draws it as one more sprite in the scene. To the rest of GPUI, a web page is a texture.
The stage 1 code was deleted, not feature-flagged. The frame slot, the byte buffer, the readback call, the R↔B swap, the per-frame image upload: all gone in the same commit. There is no fallback path.
What the fence buys, and what it did not
A sync fd is semantically single-use. Once a consumer has waited on it, waiting again has no defined meaning. So fence() takes the fence out of the handle: the first consumer in a frame gets it, everyone after gets nothing and falls back to a coarser barrier.
That is the design. The honest part is what shipped with it. The commit that wired the bridge end to end lists the fence wait on the GPUI side as still pending — deferred to keep the commit focused — and states the consequence in the same sentence: without it the consumer samples whatever is in memory at draw time, with the risk of reading partial GL writes on busy frames. The producer was exporting a correct fence to a consumer that was not yet waiting on it. It looked fine because a light page rarely loses that race, not because the race was closed.
Three other items were deferred in the same breath: a defensive colour-format probe, a feature flag to fall back to stage 1, and the benchmark.
That last one deserves to be said plainly. Stage 2 has never been measured. The commit guessed at numbers for both paths and neither guess was ever tested. The claim this entry makes is structural — three CPU passes over the framebuffer became zero — and structural is all it is. If you want a figure for what that is worth on your hardware, it does not exist.
Blurry for a day
The first visual cut worked and looked wrong. Text was soft. Everything was.
Servo was rendering at the logical size while GPUI composites at physical pixels — logical multiplied by the display's scale factor, typically 1.5 to 2 on a modern panel. The dma-buf carried the smaller image and GPUI's sampler stretched it at sample time. Zero-copy, correct, and blurry.
The fix runs at engine construction: query the window's scale factor, multiply the requested logical size by it, and pass Servo both the physical size and the scale factor. hidpi_scale_factor went from a hardcoded 1.0 to the real value, so Servo's font metrics, layout em sizes and canvas DPI line up with what GPUI expects to composite. The dma-buf is then exactly the size of the target area and the sample is 1:1.
A closely related mismatch was left open. If the window manager forces a size Servo was never told about — a tiling WM, a fractional-scaling setup — the quad is stretched again, and at this point ServoEngine::resize existed but was never called from anywhere. That went into the backlog as “Window resize → Servo WebView resize”, blocked on servo/servo#38369, where resizing the rendering context panicked. It stayed open for six weeks, until tabs forced a rewrite onto swappable offscreen FBOs and closed it as a side effect.
Mouse and scroll forwarding landed two hours after the bridge, the same morning. Keyboard did not. Keys were dropped on the floor for another six weeks.