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.
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.
| Metric | Old image | New image |
|---|---|---|
| Browser CPU | 4.71 cores | 3.47 cores |
| Browser wake-ups/s | 53,297 | 18,428 |
| Host CPU idle | 33.9 % | 43.8 % |
| avplumber CPU | 4.06 cores | 3.85 cores |
| GPU SM | 58.8 % | 57.5 % |
| VRAM total | 10,064 MiB | 10,085 MiB |
| VRAM of browser processes | 1,272 MiB | 1,265 MiB |
| Repeated source frames | 0 | 0 |
| Median cut latency | 70 ms | 75 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
- The socket thread waits in
poll(). Before, each window's accept thread triedaccept(), read acks from every client, then slept 1 ms: about 1000 wake-ups a second per window. Now it blocks inpoll()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, sopoll()cannot spin. - 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 withdup3, so connected consumers keep their frames. If that fails, the file is unlinked, since nothing listens behind it, and the next check retries. Before,createServerreturned early and the file stayed gone. - 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, anydid-fail-loadtore 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. - 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
heldand its texture goes straight back to Chromium, so the mixer'srepeat_last_framekeeps 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 firstdid-finish-loadwith no failure since the last reload; Chromium also firesdid-finish-loadfor 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./statuscounts held paints underdroppedReasons.held. The window option isholdLastFrame(defaulttrue); the mixer sets it from each browser source'shold_last_frame, and--dmabuf-openwindows always hold. The mixer checks that the browser service echoesholdLastFrameback, as it already did forringSize: 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. - Per-window frame queue. A show's
browser_frame_queue(1 to 9, default 9 at every rate; the earlier keybrowser_ring_sizeis still read), sent as each window'sringSize, 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-wideDMA_BROWSER_DMABUF_POOL_SIZEapplies (default 11). - Two no-op nodes dropped. Each browser window's mixer chain ran an
assume_video_formatnode and asettbfilter_video, each on its own thread. Neither did anything:drm_prime_to_cudareads geometry and format from every frame, andsmooth_timestampsalready 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_sourcetakespremultiplied_alpha) and, since 2026-10-13, to the source's fan-out, which stampsalpha_mode=premultipliedwith 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
- WhatMostly browser sender changes, in
deps/dma-browser:- the fd-passing thread waits in
poll(); - a vanished capture socket is re-bound;
- failed loads and renderer crashes reload the page;
- the window holds its last frame while a failed or crashed page reloads;
- a per-window frame queue.
On the mixer side only Python changed: each browser source's
hold_last_framereaches its window, a browser service that ignores the option stops the mixer at start, each window'sassume_video_formatandsettbfilter_videonodes are gone, and Chromium's premultiplied alpha is tagged on blended browser sources and keys, at first by the colour node and now by the source's fan-out. The avplumber binary is unchanged. - the fd-passing thread waits in
- WhyWith 40 windows, the 1 ms loop alone meant about 40,000 wake-ups a second, most of them finding nothing. A page that failed to load, or whose renderer crashed, froze its source with no alarm. With reloads in place, a failed page's source showed Chromium's empty error page, transparent or black, until a reload loaded cleanly.
- Latency0 frames added to program and to command-to-screen: no queue, buffer or node added; item 6 only removes two per window. Acks are read as they arrive instead of at the next 1 ms tick, so each frame returns to the browser's pool up to 1 ms sooner. Median cut latency measured 70 ms old and 75 ms new, within run-to-run noise. Holding adds no queue either: the mixer repeats the last frame at the canvas rate, as it already does for a page that stops painting.
- Side effects
While a page is down, Chromium paints its own empty error page. After a main-frame load failure or a renderer crash, a window that has already delivered a frame no longer sends it: the sender drops paints until a reload loads cleanly, and the mixer's
repeat_last_framekeeps showing the last frame.A page that fails its first load has no frame to hold, so its error page is sent until one frame reaches a consumer, and then held. The source shows transparent or black until a clean load, but the mixer's chain gets a frame and can start.
A load watchdog timeout alone does not hold: a slow page is reloaded but keeps sending its live paints. A browser source with
hold_last_frame: falsepasses the error page through, transparent or black until the next clean load. Sub-frame failures andERR_ABORTEDare only logged. - Cost0 nodes, 0 queues, 0 VRAM for the sender changes: browser processes held 1,272 MiB before and 1,265 MiB after. Item 6 removes two nodes and their queues from every browser window's chain: avplumber threads 555 → 491 on the 68-input show. One eventfd per window; each wake-up rebuilds the
pollfdlist of the eventfd, the listener and the window's clients. The hold is one flag check per paint, and a held paint's texture is released at once. Code: in the fd-passing addon, +51/−28 lines for thepoll()loop and +39/−16 for the re-bind; a 60-line page reloader with tests. The hold: +32/−8 lines in the browser sender and +13/−5 in the mixer's Python, without tests and docs. - Risk
Low. The
poll()loop's hazards are covered: acks are read beforeaccept(), removed descriptors are skipped,removeClient()callswake()becauseclose()does not end apoll(), and a persistent error pauses 1 ms. A URL that never loads retries every 30 s indefinitely, logged each time.From the code, not observed: the “frame to hold” test counts frames over the window's lifetime, not per consumer. The mixer reuses a matching window, and its start-up refresh ends a hold only if the page then loads cleanly. So a mixer restarted while a reused window is held gets no frame from it until the page loads cleanly, and start-up fails after
--preheat-timeout(60 s by default). Closing that window first, so the mixer opens a new one, or settinghold_last_frame: falseon the source avoids it. - Switch it offNo runtime switch for the reload.
hold_last_frame: falseon a browser source turns off only the hold, for that window. To remove the changes, revert the browser and mixer commits together and rebuild the browser image: a mixer that sendsholdLastFrameto a browser service without it stops at start. The avplumber binary needs no change.
Where in the code
- deps/dma-browser/addons/fdpass/addon.cc:
wake()andacceptLoop(), onepoll()with acks read before accepting; source the 1 ms pause on a persistent error. - addon.cc: a descriptor no longer in the client list is ignored; source
removeClient()retires the client's frames, closes it and callswake(). - addon.cc:
rebind(),dup3under the same descriptor, unlink on failure; sourcecreateServerre-binds a server whose file vanished. - deps/dma-browser/src/main/capture/FrameCaptureChannel.ts: the 1 s socket monitor that calls
createServer. - deps/dma-browser/src/main/PageReloader.ts: which load failures count, and the doubling backoff.
- deps/dma-browser/src/main/ManagedWindow.ts: load failures, the watchdog and renderer crashes all reload the page; source the 1 s and 30 s delays.
- ManagedWindow.ts:
recover()holds for a load failure or a crash; the watchdog passeshold = false. source a cleandid-finish-loadends the hold; source the window reportsholdLastFrame, which the mixer checks. - FrameCaptureChannel.ts:
hold(); source a paint is dropped asheldonly once a frame went out; source a frame counts as sent only when a consumer received it. - PageReloader.ts:
loaded()is false after a failure since the last reload, so the error page's owndid-finish-loaddoes not end the hold. - deps/dma-browser/src/main/config/ConfigService.ts:
holdLastFramedefaults totrue; WindowConfig.ts the window option. - pyplumber/mixer/config.py: a source's
hold_last_frame, defaulttrue; source a config error unless it is a boolean on a browser source. - pyplumber/mixer/application.py: the source's
hold_last_framebecomes its window'sholdLastFrame. - pyplumber/mixer/dmabuf_inputs.py:
--dmabuf-openwindows always hold; source the mixer stops when the browser service does not applyringSizeorholdLastFrame; source therepeat_last_framenode that repeats the last frame at the canvas rate. - dmabuf_inputs.py: why the chain has no format or time-base node of its own: geometry and format come from each frame, and
smooth_timestampsstamps in 1/fps; sourcesmooth_timestampswrites the chain's output edge directly, with no filter after it. - pyplumber/mixer/graph.py (pinned to
developat 8fa15c92):_frame_coloraddsalpha_mode: premultipliedto the tags the fan-out stamps for a source registered withpremultiplied_alpha; pyplumber/mixer/application.py sets it for the browser sources that scenes blend or downstream keys use. - deps/dma-browser/src/main/Application.ts: the window's
ringSize, else the process-wide pool size. - Docs: dma-browser README · Frame lifetime and transport counters (the ack protocol); the paragraph above it describes page recovery and
holdLastFrame, but not yet its two exceptions: a page that fails its first load still sends its error page, and a watchdog reload keeps sending live paints. Mixer config · sources listshold_last_frame. Browser CPU is per painted frame puts the hand-off in Chromium's per-frame cost: the browser's main process (paint event, fd pass, release ack) is about 6 % of a core per window at 60 paints/s.
Source links are pinned to the reviewed revision.