Local Windows 11 build VM

A Windows 11 VM on your own Linux machine with the farm's exact toolchain

A local Windows 11 build VM (libvirt)

Run MSVC builds on your own Linux machine in a Windows 11 VM that gets the same toolchain as the farm and mathilde64: MSVC 14.44.35207, Qt 6.10.1 msvc2022_64, qbs 3.3.1 in C:\qbs, ccache, and profile qt6. One script does the setup, integration/winvm/winvm, and one file holds the configuration, integration/winvm/winvm.conf.

This is the route for developer machines. The CI farm keeps its dockurr/windows container, described in the main page; the two share nothing except the provisioning script.

How it fits together

Part Choice Why
Hypervisor libvirt session instance (qemu:///session) QEMU, swtpm, passt and virtiofsd all run as you. Nothing needs root after the package install, and nothing the VM owns needs sudo to remove
Firmware OVMF with Secure Boot and Microsoft keys, TPM 2.0 from swtpm Windows 11's own requirements, met rather than bypassed
Disk Sparse qcow2 on virtio, discard=unmap Grows as it fills, and shrinks again when Windows trims
Network passt user-mode networking, host ports on 127.0.0.1 No bridge, no firewall rules, no Docker NAT to masquerade
Shared folder virtiofs, seen by Windows through WinFsp Build artifacts land on the host as ordinary files you own
Source integration/devsync win11vm, a real git checkout Shared folders have lost files under builds before; git gives provenance
Toolchain The farm's provision-vm.ps1, vendored and pinned What mathilde64 was provisioned with, then checked against toolchain-requirements.json

Everything the VM owns lives in state_dir, /build/win11-vm by default, outside every git worktree:

/build/win11-vm/
  cache/         ISOs and installers, with SHA256SUMS
  disks/         current.qcow2, the writable top layer
  nvram/ tpm/    UEFI variables and TPM state
  snapshots/     one directory per snapshot: frozen disk layer, VARS.fd, tpm/
  secrets/       guest password, answer CD, known_hosts (mode 700)
  share/         the shared folder

Requirements

Need Default Notes
KVM /dev/kvm Your user must be in group kvm. The desktop session gets access through a logind ACL, but SSH logins and timers do not
CPU 16 vCPUs Must be even, and leave threads for the host
RAM 32 GiB winvm start refuses while less than that is available. Freed guest memory goes back to the host (free page reporting)
Disk 250 GiB, sparse Plan on about 120 GiB actually used for Windows, the toolchain and one LLVM build
Ports 2224 SSH, 3390 RDP, 5911 VNC, all on 127.0.0.1 Chosen not to collide with the farm container's 2223 and 3389
Packages Debian 13 See below
sudo apt-get install --no-install-recommends \
  qemu-system-x86 qemu-utils libvirt-daemon-system libvirt-clients \
  ovmf swtpm swtpm-tools passt virtiofsd xorriso curl jq openssh-client patch git
sudo usermod -aG kvm "$(id -un)"     # then log out and back in

winvm check checks all of this and changes nothing:

$ integration/winvm/winvm check
==> Host requirements for 'octomy-win11' (.../integration/winvm/winvm.conf)
  ok    /dev/kvm is usable
  ok    packages present
  ok    libvirt answers on qemu:///session
  ...
0 failure(s), 0 warning(s)

Which Windows

iso_source in winvm.conf picks the image:

  • evaluation (default): Windows 11 Enterprise Evaluation, from a stable official Microsoft link. It always downloads unattended. It expires 90 days after install: slmgr /rearm inside the guest extends it, and a reinstall resets it.
  • consumer: the retail multi-edition ISO, installed as Pro with the generic key. It runs unactivated with no expiry. It is fetched through Microsoft's download API, the same way dockurr/windows does it, and Microsoft's bot filter rejects that from some networks. When that happens, winvm stops and prints the manual route: download the ISO in a browser and save it as cache/win11-consumer.iso.

The Windows ISO has no published digest, so its SHA-256 is recorded the first time it is downloaded and checked on every later use. virtio-win, WinFsp and the OpenSSH MSI are pinned by digest in winvm.conf.

Setup

integration/winvm/winvm create      # unattended install, then snapshot "installed"
integration/winvm/winvm provision   # toolchain, then snapshot "provisioned"

On webstep9 (2026-09-17, Enterprise Evaluation build 26200, 16 vCPUs), create took 21 minutes, including Windows' own update check during setup, and provision took 17 minutes.

create downloads what is missing, builds an answer CD with a random guest password and your public key, defines the domain and starts the install. It returns once the guest answers over SSH. A first logon script, guest/firstlogon.ps1, installs:

  • the virtio guest tools
  • WinFsp, with the virtiofs service set to start automatically
  • OpenSSH Server, from the Windows optional feature, or from the pinned MSI if Windows Update cannot be reached

sshd then allows keys only, the key file's ACL is set by SID, and DefaultShell is left unset, so a session gets stock cmd.exe as in CI. To watch progress, connect a VNC viewer to 127.0.0.1:5911, or run winvm screenshot /tmp/win.png.

provision copies the vendored provision-vm.ps1 into the guest and runs it x64-only. It then fails unless the result matches:

  • the qbs and Qt floors, and the qbs known-bad list, in integration/toolchain-requirements.json
  • the exact qt_version, qbs_version and msvc_toolset in winvm.conf
  • ccache, git, the qt6 profile and C:\build-environment.json all present

It writes the versions to C:\octomy-vm\provisioned.json, with a copy in state_dir, then reboots so the installers' PATH changes reach sshd. Re-running it is safe.

Daily use

integration/winvm/winvm start
integration/devsync win11vm --verify             # committed HEAD -> C:/octomy
integration/winvm/winvm run fullbuild "C:\qbs\bin\qbs.exe build -d C:\build -f C:\octomy\project.qbs profile:qt6 config:release -j 8 --job-limits linker:2 --keep-going"
integration/winvm/winvm wait fullbuild            # exits with the build's own code
integration/winvm/winvm stop

Start it to build, and stop it when the build is done. A running guest holds its memory whether it is building or idle, and on a machine shared with other builds that memory is theirs. Give the VM what a resolve needs (32 GiB) while it runs rather than a smaller size it keeps all day.

Run long builds with run, not ssh. Windows OpenSSH kills everything a session started when the connection drops, so an ssh "qbs build …" dies with its connection — including when the guest reboots under it. run starts the command outside that session, writes its output to /build/win11-vm/share/logs/<label>.log while it runs, and leaves the exit code in <label>.rc; wait blocks on it and returns that code. Use ssh for short commands.

The VM does not update itself. Windows Update is set to notify only, its automatic reboot is off, and wuauserv and UsoSvc are disabled (guest/firstlogon.ps1). On 2026-09-18 an automatic update rebooted the VM ten minutes into a full build and threw away a 98-minute resolve. Update it deliberately instead: re-enable the two services, install what you want, and disable them again. Snapshot first.

devsync and devnode reach the VM through its win11vm entry in integration/devnodes.json, which names the loopback port and the key. Anything written to the share drive in the guest shows up in /build/win11-vm/share on the host, owned by you. The share's letter is the first one free counting down from Z:.

For a desktop, RDP to 127.0.0.1:3390. The account is octomy, and the password is in secrets/guest-password.

Every suite, built on the host

cross-sweep (src/tools/cross-sweep) runs the whole Windows test set the way kiln's Windows gate was answered (kiln testing.md §KN-V6): the host cross-builds twelve suites at a time with kiln --os windows build into the share (kiln-file §KN-K26), while the guest runs the previous twelve with kiln test --prebuilt, and each batch's programs are deleted once they have run. It starts the VM, and stops it on every way out it controls, an error or Ctrl-C included.

cross-sweep --kiln <kiln> --broker <broker> --variant release --sweep rel1 \
    --guest-path 'C:\Qt\6.10.1\msvc2022_64\bin,C:\Qt\Tools\OpenSSLv3\Win_x64\bin' \
    --reference /build/win11-vm/share/logs/kw16-msvc.txt,/build/win11-vm/share/logs/kw24-msvc.txt

The guest's checkout must be the host's commit (devsync first), and its kiln is Z:\kiln-win\debug\kiln\kiln.exe unless --guest-kiln names another. The sweep's folder is /build/win11-vm/share/sweeps/<name>: the suite list, each batch's build log and guest log, each suite's tests.json, and verdicts.tsv, kiln's verdict beside the MSVC run's for every suite with a count per outcome. --from <batch> resumes, and --table-only rewrites the table from the reports already there.

The guest's kiln has to know every key the checkout's .kiln files use: one older than a new key refuses the description, and every suite of the sweep comes back without a result. Build it from the same commit on the host first, with kiln --os windows build --variant debug src/tools/kiln.

Windows packages, made on the host

kiln package zip, kiln package msix and kiln package setup make the build farm's three Windows packages from a Windows build on the host (kiln-file §KN-K30). The zip needs no VM: kiln reads which DLLs and Qt plugins the programs need and copies them from the sysroot. That needs the runtime in the sysroot as well as the toolchain. A sysroot made by winvm sysroot before 2026-10-09 has only the toolchain, so add the runtime to it once:

winvm runtime /build/winsysroot

This copies Qt's DLLs, plugins, resources and translations, the Visual C++ redistributable and OpenSSL's DLLs out of the VM, without their symbol files. The msix and the installer are packed in the VM, by the Windows SDK's makeappx and Inno Setup's ISCC. kiln starts the VM for that and stops it again if it was off.

A packaged application starts only in a logged-on desktop session, which a winvm run command does not have. Since 2026-10-09 the VM logs the octomy user on at the console (Winlogon's AutoAdminLogon, with the password from secrets/guest-password), and a check that starts an msix application runs from a scheduled task with the interactive flag (schtasks /IT).

Snapshots

winvm stop
winvm snapshot save before-llvm
winvm snapshot restore provisioned     # back to a clean toolchain
winvm snapshot list

A snapshot is a frozen qcow2 layer plus copies of the UEFI variables and the TPM state. Windows binds those to the disk, so the three are restored together. Saving freezes the current layer and starts a new overlay on it. Restoring recreates the overlay on the chosen layer. Neither step copies the disk or deletes a file, so both finish in well under a second (45 ms measured). The VM must be shut off for either.

Troubleshooting

Links fail with C1002: compiler is out of heap space in pass 2, then the disk fills. With /GL, every app and test executable does its code generation at link time, and -j 12 lets twelve of those run together in 24 GiB. On 2026-09-25 they paged for three hours, ten failed with C1002 / LNK1257, and the pagefile grew from its 32 GB base to its 64 GB ceiling, which left C: with 0.6 GB and failed the next build with LNK1180: insufficient disk space. Cap the links rather than the compiles: qbs build … -j 8 --job-limits linker:2. Windows returns a grown pagefile to its base size only at boot, so winvm stop then winvm start gets the 32 GB back.

A resolve seems to hang, and SSH times out. A resolve of the whole tree needs about 32 GB, as it does on Linux. With memory_gib = 24, the setting until 2026-09-25 on webstep9, qbs pages. Measured 2026-09-25: 32 GB private and 13 GB resident, with its main thread waiting on PageIn, a few CPU-seconds a minute, and an sshd too starved to finish the banner exchange. It is still making progress, so let it run. The QEMU guest agent still answers while sshd does not (the qemu-agent-command route under "create times out"; guest-exec runs a command), and (Get-Process qbs).Threads | Group-Object WaitReason tells paging from a hang. memory_gib = 32, the current setting, avoids it.

create times out. Take a screenshot. If first logon failed, the guest has C:\octomy-vm\failed.txt and C:\octomy-vm\firstlogon.log. Read them over VNC, or without SSH through the QEMU guest agent, which the virtio guest tools install: virsh -c qemu:///session qemu-agent-command octomy-win11 '{"execute":"guest-file-open","arguments":{"path":"C:\\octomy-vm\\firstlogon.log"}}', then guest-file-read on the handle it returns.

provision says the MSVC toolset does not match. The Build Tools bootstrapper installs the newest 17.x toolset. Don't just move the pin in winvm.conf: the pin is what makes results comparable with mathilde64. Move it only together with the farm.

SSH says the host key changed. That happens after a reinstall. Clear the old key with ssh-keygen -R '[127.0.0.1]:2224' for devsync. winvm's own known_hosts file is reset by create.

Start over.

virsh -c qemu:///session destroy octomy-win11
virsh -c qemu:///session undefine --nvram octomy-win11

Then remove disks/, nvram/, tpm/ and snapshots/ under state_dir by hand. winvm never deletes them itself.

Where things come from

  • integration/winvm/vendor/monolith/PROVENANCE.md: which monolith commit the provisioning script is, and why it carries a patch.
  • integration/winvm/winvm.conf: every version, URL and digest.
Topics
howto windows msvc build qemu libvirt
See also