Answers one question about a QEMU/KVM guest with accelerated graphics: is the guest actually presenting frames to the host, or does it only think it is?
On guests using QEMU's GL-accelerated virtio graphics (virtio-gpu-gl or
virtio-vga-gl, commonly with blob=on, which Venus/Vulkan and OpenGL 4.6
require per the
QEMU virtio-gpu documentation),
none of the usual inspection methods work.
virsh screenshot and QMP screendump fail with no surface. This was A/B
tested on the origin guest with blob=on and blob=off (2026-07-12): both fail,
so the breakage follows the GL scanout path itself, where frames travel as
dmabufs and no main-memory display surface is populated for screendump to read.
Anything built on screendump breaks with it.
Screenshots taken inside the guest (xwd, import, compositor tools) are worse
than useless for this question. A guest can render every frame correctly to its own
memory and never present one, in which case the in-guest screenshot shows a perfect
desktop while the host shows black and a mouse cursor.
A human looking at the viewer window works, but not in CI, and not nine times in a row during a bisect.
The failure mode is real, not hypothetical. This probe was written to bisect an X
server regression (XLibre commit ad1d4a28)
where kwin composited and rendered normally but no frame was ever flushed to the
scanout (the modesetting driver presents composited output through glamor, and the
commit broke glamor's GLX vendor initialization, taking the present path with it).
Every screenshot mechanism reported a healthy desktop. The screen was black.
QEMU has a trace event, virtio_gpu_cmd_res_flush, that fires once per
RESOURCE_FLUSH command, the guest telling the host "present this rectangle". The
probe enables the event, runs glxgears in the guest session so the screen is not
idle-static, and counts events over a 30-second window read from the libvirt QEMU
log.
Measured numbers from the origin case, identical guest and configuration:
working session: ~1800 events / 30 s
broken session: 0 events / 30 s
The count is taken on the host, at the virtio transport, past the point where the guest's own view of the world can lie to you.
It's one file with no dependencies beyond bash, ssh, and virsh:
curl -O https://raw.githubusercontent.com/jdmanring/virtio-gpu-presentation-probe/main/virtio-gpu-presentation-probe.sh
chmod +x virtio-gpu-presentation-probe.sh
One-time host setup, the only step the script cannot do for itself:
sudo setfacl -m u:$USER:r /var/log/libvirt/qemu/<vm>.log
The ACL is per-inode and silently dies whenever virtlogd recreates the log file. Re-grant it when the probe exits 2.
Then run it (the script arms the QEMU trace event itself on every run):
./virtio-gpu-presentation-probe.sh <vm-name> root@<guest-ip> [label] [window-seconds]
# prints (after ~45-100 s; progress goes to stderr):
myvm FLUSHES=1800 RATE=60/s SESSION=up
The method assumes QEMU was built with the log trace backend (on by default in
distro builds) and that the domain logs through virtlogd to the usual file. If a
guest you can see working reads FLUSHES=0, check that
virsh qemu-monitor-command --hmp <vm> "info trace-events" | grep res_flush shows
state 1 and that the log file is growing. A missing trace backend would otherwise
read as a false "not presenting".
Exit codes are part of the contract: 0 presenting, 1 not presenting, 2 setup or
session failure. That makes it usable directly as a git bisect run oracle or a CI
gate without parsing the output.
Everything guest- or host-specific is an environment override, documented in the
script header: VIRSH_URI for non-system libvirt, QEMU_LOG for nonstandard log
paths, SESSION_PROC for non-KDE guests, XAUTH_GLOB for non-SDDM display
managers, and DAMAGE_CMD, which replaces the whole damage generator and is also
the hook for Wayland guest sessions (an example is in the header). THRESHOLD sets
the presenting cutoff, default 100 events per window against a measured signal of
about 60/s versus 0. It is per-window, so scale it down for windows under 5
seconds. The default damage generator is pre-flighted (glxgears present, xauth
found) because its silent failure on an idle desktop would be indistinguishable
from a broken guest; if you override DAMAGE_CMD, that check is yours. Don't run
two probes against the same VM at once, since the first to exit disarms the shared
trace event mid-window for the other.
Requirements in the guest: reachable sshd with key-based auth, and glxgears
(mesa-demos or mesa-utils) unless you replace DAMAGE_CMD.
What a bad verdict tells you: exit 1 with the session up means frames are being rendered guest-side but no present command crosses the virtio transport. Suspect the guest's presentation path (DDX/modesetting driver, compositor present path) rather than virglrenderer or QEMU display code, and attach the probe line plus the guest's X or compositor log to the bug. Exit 0 with a black viewer means the guest IS presenting, so suspect the host viewer or display channel instead.
openQA verifies guest screens by matching screenshots against reference images it calls needles (openQA documentation, "Needles"). Avocado-VT captures guest screens with QEMU screendump, from a periodic screendump thread in its VM handling (virttest/env_process.py). Both therefore depend on QEMU's display capture. Screendump is verified broken on GL virtio domains (this repo's origin case, and the reason this tool exists); whether openQA's VNC-based capture survives blob scanouts was not tested here. Independent of that, both frameworks answer a different question, "does the screen match a reference", where this probe answers "is presentation traffic flowing at all".
QEMU has no screendump readback for blob/dmabuf scanouts: the series that added blob scanout support (Osipenko, "virtio-gpu: Support blob scanout using dmabuf fd") covers display, not capture. Upstream expected screendump to work with egl-headless, which copies rendered frames back to a memory surface (Lureau, qemu-discuss, "egl-headless display screenshot"). On the origin guest (virtio-vga-gl with egl-headless, QEMU via libvirt) that does not hold today: screendump returns "no surface" with blob=on and with blob=off, A/B tested 2026-07-12. Whether that is a regression against Lureau's description or was always true of the virgl scanout path is a question for QEMU upstream. A screendump fix in QEMU would be the right long-term answer. Even with one, a screenshot of a static-but-correct screen cannot distinguish "presenting" from "presented once and stopped", which an event rate can.
DRM CRC checks, the kernel's frame-verification mechanism used by igt-gpu-tools (kernel documentation, "Display CRC Support"), run on the guest side of the virtio boundary, where this failure is invisible by construction: the guest believes it presented. In-guest tracing of the virtio_gpu driver observes the same commands but from the same untrusted side. This probe sits on the host, at the transport, which is the first place the truth is observable.
I found no existing tool that measures this. If one exists, file an issue and I will link it here.
The need is documented by its absence. Black-screen-with-virgl threads recur for years and resolve, when they resolve at all, by guesswork: a GNOME Boxes bug identified only after exhausting configuration changes (Arch BBS 264920), a known NVIDIA-host incompatibility found via Reddit hearsay (Arch BBS 302370), a compositor conflict isolated by trial and error (Gentoo forums), and GL display modes that plainly do not work (UTM #4941). None of those threads had a way to answer the first question a display developer would ask: is anything being presented?
Tested environment: Linux host (the script needs setfacl, virtlogd's log layout, and virsh, so the host side is Linux-only), QEMU 11.0.2, libvirt 12.5.0, bash 5. The measured rates and the screendump A/B result are facts about this stack; if your QEMU behaves differently, especially around screendump, that is worth reporting here and upstream.
Every path was exercised end-to-end against a live libvirt domain (2026-07-12), not just the happy one:
| path | how it was produced | result |
|---|---|---|
| exit 0, X11 session | working X server (XLibre 25.2.0), Plasma X11 | FLUSHES=1801 RATE=60/s SESSION=up |
| exit 0, Wayland session | Plasma Wayland, SESSION_PROC=kwin_wayland, DAMAGE_CMD=eglgears_wayland |
FLUSHES=1801 RATE=60/s SESSION=up |
| exit 1, real failure | X server built at the regressing commit (renders, never presents) | FLUSHES=0 RATE=0/s SESSION=up |
| exit 2, unreadable log | QEMU_LOG=/nonexistent |
error to stderr |
| exit 2, unknown domain | wrong VM name | trace-arm error to stderr |
| exit 2, no session | greeter only, no session process | FLUSHES=2250 SESSION=down |
The last row is why the session gate exists. An SDDM greeter alone can present at up to ~75 events/s while animating (it measures 0 when static), so a flush count without the session check could call a dead login loop "good". The count is still printed; the verdict is withheld.
examples/xserver-bisect-harness.sh is the origin use case: an unattended
git bisect of an X server inside a guest, using the probe as the good/bad oracle
(build, display-manager restart into an autologin session, probe, git bisect good|bad). It found the regressing commit in 9 steps with no one watching the
screen. Paths and meson options in it are specific to that hunt; treat it as a
template.
Bug reports and portability fixes (other display managers, other session types) are welcome; CI runs shellcheck on both scripts, so run it on your patch first. Scope is deliberately frozen: virtio-gpu only. Passthrough has no virtio boundary for the host to observe, and QXL domains don't need a probe, since screendump works there. Feature requests that widen the scope will be declined with thanks.
MIT.