KWin can go away under a running daemon while the session bus stays: it crashed, or kwin_wayland --replace took its place. Its windows go with it, and so does the bridge, which would have reported them closing; in the spikes the old windows vanished without a single removed event. What the daemon does see is the bus, so the thread that serves the connection watches NameOwnerChanged for org.kde.KWin. When the name loses its owner, Table::compositor_lost marks every window closed and returns a Destroy for each, so the window manager takes them out of its layout, and their ids are not given out again. When the name has an owner again, the daemon loads the bridge into the new KWin, trying for up to 15 seconds while KWin is not ready for it, and the bridge reports the new desktop in a snapshot. zbus does not end a held call when its caller leaves the bus, so the Poll of the old bridge is still waiting when the new bridge calls, and it was woken first: it took the commands for the new KWin and answered them to nobody. vm-kwin-restart lost the tile of a window opened in the third KWin that way in one run of three, and a recording of the bus showed the batch going to the old bridge. A Poll held since before KWin last left the bus is answered with nothing and passes its wake-up on to the next call, which a unit test holds. The VM suite vm-kwin-restart (from the spike's spike7-resync) restarts the headless KWin twice under a running daemon, through Kde.restart_kwin in the VM library, and checks that the daemon sees KWin go, takes the old windows out of its layout, loads the bridge into the new KWin, and tiles the windows that open there.
9.2 KiB
VM tests
The unit tests run against a fake desktop. They cannot say whether KWin does what the daemon asks of it, so a second kind of test boots a NixOS virtual machine with Plasma in it, starts a real KWin, and drives it. This page says how they are laid out, how to run them, and how to add one.
What runs where
| Where | How | Accelerator |
|---|---|---|
CI, x86_64-linux |
nix build -L .#checks.x86_64-linux.vm-NAME |
KVM |
| A Linux machine with KVM | just vm NAME |
KVM |
| A Mac | just vm NAME, which runs scripts/vm-mac |
HVF |
| A Linux machine with no KVM | nix build -L .#legacyPackages.SYSTEM.vmTcg.NAME |
TCG |
CI runs the fast tier on every pull request and the slow tier on master,
every week, and on pull requests labeled ci:vm-slow
(ci.yaml). The slow jobs run the driver
by hand instead of building the check, so that the screenshots and journals of a
failed run can be uploaded as an artifact. KVM on GitHub-hosted runners is
documented for public repositories; the first step of each VM job checks that
/dev/kvm is writable and fails there if it is not.
TCG is software emulation. It is slow enough that only short suites are worth
running that way, and the library's scale argument (below) is 8 for it.
Each suite is a directory under
nix/tests/vm/suites. The generator in
nix/tests/vm/default.nix lists that
directory, so a new suite is a new directory and nothing else: it appears as
checks.<system>.vm-NAME on Linux and as legacyPackages.<system>.vm.NAME
everywhere. legacyPackages.<system>.vmTiers lists the names of each tier as
JSON, which is what CI reads.
A suite has two files:
default.nixsetstier("fast"or"slow"),kind(the node it runs on) andtimeoutMinutes, and may setnode, a NixOS module that adds to the node for this suite alone (more memory, the clients it starts).test.pyis the test, a Python script for the NixOS test driver. It is prefixed withSCALE = <number>, how many times slower than KVM the machine is.
Nodes
kind picks a file under
nix/tests/vm/nodes.
headless is a machine with Plasma installed and no login. Each KWin a test
starts is a pair of user units of the test user, session-bus@NAME (a private
D-Bus session bus) and kwin-virtual@NAME (kwin_wayland --virtual with its
own Wayland socket), so a test can run several sessions side by side and none
touches another. A virtual KWin has no real output, so it cannot draw: it can
place windows and answer questions about them, but it cannot take a screenshot.
A third unit, komorebi@NAME, runs the daemon in a session, built from the
commit under test and installed on the node; it is not bound to KWin's unit, so
a test can restart KWin under a running daemon.
KWin wants a DRM render node, which needs a GPU. The test driver gives every aarch64 guest one
virtio-gpu-pci; on x86_64 the node adds it, unless komorebi.vm.renderNode is false.
session is the headless node plus a login: plasma-login-manager logs the test
user in by itself, as in nixpkgs' own plasma6 test, and Plasma draws on the
machine's display device. It is the node for anything that needs pixels. The
choice of login manager is made in
nodes/session.nix and nowhere else;
the file says how to switch to sddm, the other one that has been seen to work.
Its display is 1280x800, because screenshots are taken of the machine's one
display device: the virtio-gpu-pci that the test driver adds to every aarch64
guest, and the default VGA of an x86_64 one. The node sets
komorebi.vm.renderNode to false, so that an x86_64 guest does not get a second
device: KWin would draw on it, and the screenshot would show the other one.
On aarch64 under HVF, at time of writing (2026-10-27), the session has the Wayland socket after 11 to 19 seconds and a painted desktop after 35 seconds.
The library
Suites import from komorebi_vm, in
nix/tests/vm/lib. It does not depend on the test
driver. It depends on the small Machine protocol, and its unit tests pass a
fake machine, so the vm-lib check tests it without booting anything. The
check also runs ruff and ty over it. The driver type checks every
test.py with ty and ruff too, so a typo in a suite fails the build, not
the run.
Checkscounts checks and fails once, at the end, so that one failure does not hide the ones after it.finish()printspassed: N failed: M.Kdeis one KWin session.Kde.headless(machine, name, size=, outputs=, scale=)starts the units and waits for KWin to own its name on the private bus.wid,rect,titleandactiveread windows throughkdotool,outputscounts the enabled outputs throughkscreen-doctor,spawnstarts a client, andwait_for(predicate, timeout)polls, with the timeout multiplied byscale.Kde.session(machine, scale=)attaches to the session node's own Plasma session once its desktop is up, andshot(name)returns a screenshot as a Pillow image, keepingNAME.pngin the output directory.activate,minimizeandcloseact on a window throughkdotool, as the user would, andwid_by_classfinds a window by its app id or X11 class.Kde.headless(..., xwayland=True)gives the session XWayland, for X11 clients.start_komorebi(*args)starts the daemon in the session,stop_komorebi()stops it withSIGTERM,komorebi_log()returns its log since it was last started, andscript_loaded()asks KWin whether it runs the bridge.restart_kwin()stops the session's KWin and starts a new one on the same bus, as when the compositor of a running session crashes, and waits for it.daemonreads the daemon's view of the desktop from that log, started with--log-level debug:daemon.events(log)lists the window events it handled, in order,daemon.window_ids(log)maps a window's title to the daemon's id for it, anddaemon.requests(log)returns the frame the daemon last asked KWin for, for each window.monitor_bridge(path)records the bridge's traffic on the session bus withdbus-monitor, andlatency.roundstimes each batch of commands in it, from the daemon's answer to the last frame KWin reports for the windows it moved.
Poll for what the test needs with wait_for. A fixed sleep is too long on
KVM and too short on TCG.
Two traps are worth knowing about before writing a screenshot test:
- A splash screen is not a blank screen. KSplash has more than a thousand colors
on it, so "the screenshot is not blank" passes while Plasma is still
starting.
Kde.sessionwaits for the splash process to exit, and a test should check a pixel it knows, such as the middle of a terminal. - NixOS wraps programs, so a process's name is the wrapper's (
.plasmashell-wrapped), andpgrep -x plasmashellnever matches. Match the command line instead, with a bracket in the pattern (pgrep -f '[p]lasmashell') so that the shell runningpgrepdoes not match itself.
Running a test on a Mac
QEMU has to run on the Mac to use HVF, and the guest is Linux, so the guest's
closure is built on a Linux builder and the driver runs on the Mac. The nix
sandbox on macOS cannot give QEMU the hypervisor, so scripts/vm-mac runs the
driver directly instead of building the check.
export KOMOREBI_BUILDER=ssh-ng://builder@localhost
export NIX_SSHOPTS="-p 31022 -i $HOME/.ssh/builder_ed25519 -o IdentitiesOnly=yes"
just vm kwin
KOMOREBI_BUILDER is the store URL of an aarch64-linux builder you can
reach. nix run nixpkgs#darwin.linux-builder starts one, with one core and 3 GB
of memory by default; a Plasma guest needs more than that (6 cores and 10 GB
worked, at time of writing, 2026-10-27), and the builder's 20 GB disk holds one
Plasma system closure of about 10 GB, so run nix-collect-garbage on it
between builds of different guests.
The script prints where the artifacts (screenshots, logs) are, under
.vm/out/NAME.
Writing a test
- Create
nix/tests/vm/suites/NAME/default.nixandtest.py. Start fromsuites/kwin, or fromsuites/kwin-eventsfor a test that runs the daemon. - Pick the tier.
fastsuites are the pull request gate and should take minutes.slowsuites run onmasterand every night. - Run it:
just vm NAME. - If the test covers behavior the daemon has, write the Rust test that covers the same behavior against the fake desktop in the same commit, and say in the VM test's first comment what only a real KWin can tell.
Add git add -N for new files first: a flake only sees files git knows about.
Looking inside a failed run
nix build -L prints the guest's console, and a session test keeps its screenshots. Run the driver by hand, as
scripts/vm-mac does, to keep the screenshots. For an interactive session
build driverInteractive instead of driver, run it, and call start_all()
and machine.shell_interact() at its prompt. In the guest, KWin's own log is
journalctl --user -u kwin-virtual@NAME as the test user, and a KWin script's
print() goes to the same journal. The daemon's log is
/run/user/1000/komorebi-NAME.log.