Historical recipe — not current setup guidance. Measurements and implementation links below belong to their original workload and revision. Deployment labels and defaults may be obsolete. Use the reviewed performance recipes; browse historical evidence.

AVPlumber · Mixer optimization cookbook

Browser frame hand-off

Implementation links are pinned to develop at c20464a5 (2026-10-09). The mixer has changed since; this page was checked against develop at 8fa15c92 on 2026-10-13. Measurements retain their original workload and scope. Watch the current 192-input L4 demo.

Current scope. The poll-based handoff and reconnect behavior remain relevant. The CPU A/B below is historical; crash/reload frame holding has a separate validation scope.

Each browser window passes its painted frames to the mixer as DMA-BUF descriptors over a Unix socket, and the mixer acks every frame back (since 2026-10-12 one ack per arriving frame, not the moment a frame is let go). The sender's socket thread now sleeps in poll() instead of waking every millisecond, a vanished socket file is re-bound, and a failed page reloads without dropping the mixer's connection. While a failed or crashed page reloads, the sender by default holds: it sends nothing more, so the mixer keeps showing the page's last frame instead of Chromium's error page.

Implemented; historical test deployment Deployed and measured: everything on this page except the hold of the last frame, which was added after the measurement below and has not been measured. The dropped no-op nodes (item 6) were measured on a later 68-input, 60 fps show, not in the table.
One window's socket thread over 20 ms, before and after Before: the thread wakes every millisecond, 20 times in 20 ms; four of those wake-ups find an ack, which waited up to 1 ms for them. After: the thread stays blocked in poll() and wakes 6 times, at the moment of each of four acks, one connection and one wake() call. Before · check, sleep 1 ms, repeat acks arrive socket thread 0 ms 20 ms 20 wake-ups in 20 ms, traffic or not. Each ack waits for the next tick, up to 1 ms. After · one poll() on the eventfd, the listener and every client ack connect wake() events socket thread blocked in poll() 6 wake-ups, each with work to do. An ack is read the moment it arrives.
Illustration, not a measurement: one window's socket thread over 20 ms. Amber: a check that found nothing. Green: an ack read. Blue: a consumer connecting. Teal: wake() for stop, re-bind or client removal. Before, every window's thread woke about 1000 times a second whatever the traffic.

Measured: old vs. new browser image

Same 110-source 1080p25 show (40 NVDEC, 36 browser pages, 30 raw NV12 files and 4 DSK key pages) on the 16 GB T4 test host, with the same avplumber build. Only the browser image changed.

MetricOld imageNew image
Browser CPU4.71 cores3.47 cores
Browser wake-ups/s53,29718,428
Host CPU idle33.9 %43.8 %
avplumber CPU4.06 cores3.85 cores
GPU SM58.8 %57.5 %
VRAM total10,064 MiB10,085 MiB
VRAM of browser processes1,272 MiB1,265 MiB
Repeated source frames00
Median cut latency70 ms75 ms

Browser wake-ups fell by about 35,000 a second, consistent with 1000 a second for each of the 40 windows. Even so, the table measures everything that changed between the two images, not the poll() rewrite alone. The 5 ms cut-latency difference is within run-to-run noise.

What changed

  1. The socket thread waits in poll(). Before, each window's accept thread tried accept(), read acks from every client, then slept 1 ms: about 1000 wake-ups a second per window. Now it blocks in poll() on an eventfd (stop, re-bind, client removal), the listening socket and every client, and reads each ack as it arrives. Acks are read before new connections are accepted, so a reused descriptor number is never taken for the old client. A descriptor already removed is ignored. A persistent error pauses 1 ms, so poll() cannot spin.
  2. A vanished socket file is re-bound. The 1 s socket monitor notices the missing file and calls createServer, which binds a new socket and moves it under the old descriptor with dup3, so connected consumers keep their frames. If that fails, the file is unlinked, since nothing listens behind it, and the next check retries. Before, createServer returned early and the file stayed gone.
  3. Failed pages reload; the capture channel stays. A main-frame load failure, a load past the watchdog (30 s by default) or a renderer crash reloads the page after 1 s, doubling up to 30 s while reloads keep failing, and logs each failure and the delay to the next reload to stderr. Sub-frame failures and ERR_ABORTED (a cancelled or superseded navigation) are ignored. The consumer keeps its connection and the frames it holds. Before, any did-fail-load tore the window down: the socket closed, the mixer quarantined its frames, and a failed re-create left the source frozen for good with no alarm. A renderer crash had no handler at all.
  4. Hold the last frame. A main-frame load failure or a renderer crash also puts the window's capture channel on hold: each paint is dropped as held and its texture goes straight back to Chromium, so the mixer's repeat_last_frame keeps showing the source's last frame instead of Chromium's empty error page or a half-loaded reload. A load watchdog timeout reloads without holding, because a slow page is still painting its own content. The hold needs a frame to hold: until the window has delivered at least one frame to a connected consumer, its paints go through even while held. The hold ends at the first did-finish-load with no failure since the last reload; Chromium also fires did-finish-load for its own error page, and that one does not count. A refresh or URL update starts a clean attempt, so its clean load also ends the hold. /status counts held paints under droppedReasons.held. The window option is holdLastFrame (default true); the mixer sets it from each browser source's hold_last_frame, and --dmabuf-open windows always hold. The mixer checks that the browser service echoes holdLastFrame back, as it already did for ringSize: a browser service too old for the option stops the mixer at start with “Browser service did not apply holdLastFrame; update dma-browser before starting the mixer”. Without the check, such a service would ignore the option, run without the hold, and have every window closed and reopened on each mixer start, because its windows never match the requested settings. Before, the error page was sent, so the source went transparent or black until a reload loaded cleanly.
  5. Per-window frame queue. A show's browser_frame_queue (1 to 9, default 9 at every rate; the earlier key browser_ring_size is still read), sent as each window's ringSize, limits how many of that window's frames may be out of the browser at once. It is not a pool of buffers: the buffers are Chromium's (Browser frames go back one per new frame). Without it, the process-wide DMA_BROWSER_DMABUF_POOL_SIZE applies (default 11).
  6. Two no-op nodes dropped. Each browser window's mixer chain ran an assume_video_format node and a settb filter_video, each on its own thread. Neither did anything: drm_prime_to_cuda reads geometry and format from every frame, and smooth_timestamps already stamps in 1/fps. Both are gone. The filter's one real job, tagging Chromium's premultiplied alpha, moved into the mixer's colour node (add_source takes premultiplied_alpha) and, since 2026-10-13, to the source's fan-out, which stamps alpha_mode=premultiplied with the colour tags; a browser source has no colour node any more (Colour tags at the fan-out). Measured on a later show than the table above (68 inputs: 18 NVDEC, 29 browser pages, 17 raw NV12 files and 4 browser key pages, 60 fps, T4 test host with 16 vCPU): avplumber threads 555 → 491, avplumber CPU 4.72 → 4.63 cores, within run-to-run noise. A cleanup, not a CPU win.

At a glance

Where in the code

Source links are pinned to the reviewed revision.