ref:ba2a8a97d753e336539b9ec21c95ace0fb19fa2d

test(e2e): add a Linux test guest that logs input events and plays a tone

An Alpine virt kernel with a reproducible initramfs (busybox, alsa-lib, aplay) built by build.sh from SHA-256 pinned downloads. The guest logs every evdev event to the serial console, plays a seamless 1000 Hz sine to the default ALSA device and prints SQGUEST READY. selftest.sh checks the guest itself on D-Bus input, QMP, PS/2, wav audio and a D-Bus AudioOutListener. Used by the guest audio (#4) and input (#5) end-to-end tests. Refs #4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: ba2a8a97d753e336539b9ec21c95ace0fb19fa2d
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-12 23:17
Parents: 1f64e75
6 files changed +1255 -0
Type
tests/e2e/qemu/guest/linux/build.sh +172 −0
@@ -1,0 +1,172 @@
#!/usr/bin/env bash
# Build the Linux test guest: an Alpine "virt" kernel plus a small initramfs that logs evdev input
# events to the serial console and plays a sine tone to the default ALSA device.
#
# Usage: build.sh [output-dir]
#
# Writes into <output-dir> (default: ${E2E_CACHE}/linux-guest-<hash of build.sh and init>):
# vmlinuz kernel (Alpine linux-virt, from the release netboot files)
# initramfs.gz initramfs: busybox, musl, alsa-lib, aplay/speaker-test, kernel modules, /init
# cmdline default kernel command line
# disk.img 1 MiB blank raw disk for harnesses that always pass a -drive (run_vm.sh)
# and prints the output directory on stdout. A complete output directory is reused as is.
#
# Environment:
# E2E_CACHE cache root (default: ~/.cache/sunshine-qemu/e2e); downloads go to
# ${E2E_CACHE}/linux-guest-downloads
# ALPINE_MIRROR Alpine mirror (default: https://dl-cdn.alpinelinux.org/alpine)
#
# Host tools: bash, curl, sha256sum, tar, gzip, unsquashfs (squashfs-tools). No root, no cpio.
#
# Every download is pinned by version and SHA-256. The release files (kernel, modloop, minirootfs)
# stay on the mirrors; the two alsa packages come from the rolling v3.24/main repository, which
# only keeps the latest build. If Alpine replaces one, the download fails with a clear message and
# the version and checksum below need a bump (see README.md). Cached downloads keep working.
set -euo pipefail
umask 022
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cache="${E2E_CACHE:-${HOME}/.cache/sunshine-qemu/e2e}"
mirror="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
alpine_branch="v3.24"
alpine_release="3.24.1"
kernel_version="6.18.35-0-virt"
alsa_lib_apk="alsa-lib-1.2.15.3-r0.apk"
alsa_utils_apk="alsa-utils-1.2.15.2-r1.apk"
# file name|path on the mirror|sha256
downloads=(
"vmlinuz-virt-${alpine_release}|${alpine_branch}/releases/x86_64/netboot-${alpine_release}/vmlinuz-virt|1e6bf9027720c75c3ed0d79171f21b5791ee40ca9795d07c7c6e04dc5ea2ae90"
"modloop-virt-${alpine_release}|${alpine_branch}/releases/x86_64/netboot-${alpine_release}/modloop-virt|78907e7cc812d555f08d4e1133d090cf11fa197370882adfe67b0a5986ccb3f9"
"alpine-minirootfs-${alpine_release}-x86_64.tar.gz|${alpine_branch}/releases/x86_64/alpine-minirootfs-${alpine_release}-x86_64.tar.gz|41f73e3cf5fa919b8aa5ca6b30dc48f0da2720776d7423e2a7748211456fe081"
"${alsa_lib_apk}|${alpine_branch}/main/x86_64/${alsa_lib_apk}|052de5639df635a03cfae1d7504ed4ed75c4e2d2cfff59efc116bb847c1f4ac4"
"${alsa_utils_apk}|${alpine_branch}/main/x86_64/${alsa_utils_apk}|783af2329ff281217460948e2f5ceed73ec20bc43fb5e7a8fd4d10b989a4fd90"
)
# Kernel modules to ship; their dependencies are added from modules.dep. Everything else the guest
# needs (i8042, atkbd, 8250 serial, virtio-pci, VGA text console, devtmpfs) is built in.
modules=(
kernel/drivers/input/evdev.ko
kernel/drivers/input/mouse/psmouse.ko
kernel/drivers/virtio/virtio_input.ko
kernel/drivers/gpu/drm/virtio/virtio-gpu.ko
kernel/sound/hda/codecs/snd-hda-codec-generic.ko
kernel/sound/hda/controllers/snd-hda-intel.ko
kernel/sound/virtio/virtio_snd.ko
)
# Userspace files taken from the alsa packages.
alsa_files=(
usr/lib/libasound.so.2
usr/lib/libasound.so.2.0.0
usr/share/alsa
usr/bin/aplay
usr/bin/speaker-test
usr/bin/amixer
)
for tool in curl sha256sum tar gzip unsquashfs; do
if ! command -v "${tool}" > /dev/null; then
echo "build.sh: missing host tool: ${tool}" >&2
exit 2
fi
done
hash="$(cat "${BASH_SOURCE[0]}" "${script_dir}/init" | sha256sum | cut -c1-16)"
output="${1:-${cache}/linux-guest-${hash}}"
if [[ -f "${output}/vmlinuz" && -f "${output}/initramfs.gz" && -f "${output}/cmdline" && -f "${output}/disk.img" ]]; then
echo "${output}"
exit 0
fi
downloads_dir="${cache}/linux-guest-downloads"
mkdir -p "${downloads_dir}"
for entry in "${downloads[@]}"; do
IFS='|' read -r name path sum <<< "${entry}"
file="${downloads_dir}/${name}"
if [[ -f "${file}" ]] && echo "${sum} ${file}" | sha256sum --check --status; then
continue
fi
echo "build.sh: downloading ${mirror}/${path}" >&2
if ! curl -fsSL --retry 3 -o "${file}.part" "${mirror}/${path}"; then
rm -f "${file}.part"
echo "build.sh: download failed: ${mirror}/${path}" >&2
echo "build.sh: if this is an alsa package, Alpine replaced it; bump its version and checksum in build.sh" >&2
exit 1
fi
if ! echo "${sum} ${file}.part" | sha256sum --check --status; then
echo "build.sh: checksum mismatch for ${name}: expected ${sum}, got $(sha256sum "${file}.part" | cut -d' ' -f1)" >&2
rm -f "${file}.part"
exit 1
fi
mv "${file}.part" "${file}"
done
work="$(mktemp -d "${TMPDIR:-/tmp}/sq-linux-guest.XXXXXX")"
trap 'rm -rf "${work}"' EXIT
root="${work}/root"
mkdir -p "${root}" "${work}/alsa" "${work}/modloop" "${work}/out"
# 1. userspace: Alpine minirootfs (busybox + musl), without the package manager
tar -xpzf "${downloads_dir}/alpine-minirootfs-${alpine_release}-x86_64.tar.gz" -C "${root}"
rm -rf "${root}/etc/apk" "${root}/lib/apk" "${root}/usr/share/apk" "${root}/var/cache" "${root}/sbin/apk"
# 2. alsa-lib and alsa-utils (an apk v2 package is a series of gzip tar streams; tar reads them all)
for pkg in "${alsa_lib_apk}" "${alsa_utils_apk}"; do
tar -xpzf "${downloads_dir}/${pkg}" -C "${work}/alsa" --warning=no-unknown-keyword
done
for path in "${alsa_files[@]}"; do
mkdir -p "${root}/$(dirname "${path}")"
cp -a "${work}/alsa/${path}" "${root}/${path}"
done
# 3. kernel modules: the listed ones and their dependencies, plus the depmod indexes
unsquashfs -q -n -d "${work}/modloop/m" "${downloads_dir}/modloop-virt-${alpine_release}" > /dev/null
moddir="${work}/modloop/m/modules/${kernel_version}"
if [[ ! -f "${moddir}/modules.dep" ]]; then
echo "build.sh: ${kernel_version} not found in modloop-virt" >&2
exit 1
fi
needed=()
for module in "${modules[@]}"; do
line="$(grep -E "^${module}:" "${moddir}/modules.dep" || true)"
if [[ -z "${line}" ]]; then
echo "build.sh: module ${module} not in modules.dep" >&2
exit 1
fi
read -r -a parts <<< "${line/:/}"
needed+=("${parts[@]}")
done
target_moddir="${root}/lib/modules/${kernel_version}"
for module in $(printf '%s\n' "${needed[@]}" | sort -u); do
mkdir -p "${target_moddir}/$(dirname "${module}")"
cp "${moddir}/${module}" "${target_moddir}/${module}"
done
cp "${moddir}"/modules.{dep,alias,softdep,builtin,builtin.modinfo,order,symbols,devname} "${target_moddir}/"
# 4. guest init and build metadata
install -m 0755 "${script_dir}/init" "${root}/init"
mkdir -p "${root}/dev" "${root}/proc" "${root}/sys" "${root}/run" "${root}/tmp"
cat > "${root}/etc/sunshine-qemu-guest" << EOF
alpine=${alpine_release}
kernel=${kernel_version}
alsa=${alsa_lib_apk%.apk} ${alsa_utils_apk%.apk}
build=${hash}
EOF
# 5. initramfs: a newc cpio owned by root, created with the guest's own busybox through its musl
# loader so the host needs no cpio. Sorted input and fixed mtimes keep it byte-for-byte stable.
busybox=("${root}/lib/ld-musl-x86_64.so.1" --library-path "${root}/lib" "${root}/bin/busybox")
find "${root}" -exec touch -h -d @0 {} +
(cd "${root}" && find . -mindepth 1 | LC_ALL=C sort | "${busybox[@]}" cpio -o -H newc -R 0:0 --ignore-devno --renumber-inodes 2> /dev/null) \
| gzip -9 -n > "${work}/out/initramfs.gz"
cp "${downloads_dir}/vmlinuz-virt-${alpine_release}" "${work}/out/vmlinuz"
echo "console=ttyS0 loglevel=4" > "${work}/out/cmdline"
truncate -s 1M "${work}/out/disk.img"
mkdir -p "$(dirname "${output}")"
rm -rf "${output}"
mv "${work}/out" "${output}"
echo "${output}"
tests/e2e/qemu/guest/linux/init +302 −0
@@ -1,0 +1,302 @@
#!/bin/sh
# /init of the sunshine-qemu Linux test guest (busybox sh, PID 1).
#
# Loads the input, sound and GPU drivers, then:
# - logs every evdev event of every input device to the serial console,
# - plays a continuous sine tone (1000 Hz by default) to the default ALSA device,
# - prints "SQGUEST READY" once both are running,
# - shows a banner and a shell on the framebuffer console (tty1).
#
# Every line this script writes to the log device starts with "SQGUEST "; see README.md for the
# exact line formats. Options come from the kernel command line (all optional):
# sq.log=ttyS0 log device under /dev (default ttyS0; hvc0 for a virtio console)
# sq.evdev=1 0 disables the evdev logger
# sq.tone=sine sine: generated PCM piped into aplay; speaker-test: speaker-test -t sine;
# off: no audio
# sq.tone_freq=1000 tone frequency in Hz (integer)
# sq.tone_rate=48000 PCM sample rate
# sq.tone_amp=16384 sine amplitude (s16, 32767 is full scale)
# sq.tone_dev=default ALSA PCM name
# sq.tone_card= ALSA card index or id used by "default" (ALSA_CARD); default: first card
# sq.gpu=1 0 skips virtio-gpu (the VGA text console stays)
# sq.screen=shell shell: banner and a root shell on tty1; events: evdev lines on tty1;
# banner: banner only
# sq.settle=20 upper bound in seconds for input and sound device probing
# sq.debug=0 1 keeps kernel messages on the console after boot
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
umask 022
mount -t devtmpfs devtmpfs /dev 2> /dev/null
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t tmpfs -o mode=0755 tmpfs /run
mount -t tmpfs -o mode=1777 tmpfs /tmp
mkdir -p /dev/pts /dev/shm
mount -t devpts devpts /dev/pts
mount -t tmpfs -o mode=1777 tmpfs /dev/shm
if (exec < /dev/console) 2> /dev/null; then
exec < /dev/console > /dev/console 2>&1
fi
opt_log=ttyS0
opt_evdev=1
opt_tone=sine
opt_tone_freq=1000
opt_tone_rate=48000
opt_tone_amp=16384
opt_tone_dev=default
opt_tone_card=
opt_gpu=1
opt_screen=shell
opt_settle=20
opt_debug=0
for arg in $(cat /proc/cmdline); do
case "${arg}" in
sq.log=*) opt_log="${arg#*=}" ;;
sq.evdev=*) opt_evdev="${arg#*=}" ;;
sq.tone=*) opt_tone="${arg#*=}" ;;
sq.tone_freq=*) opt_tone_freq="${arg#*=}" ;;
sq.tone_rate=*) opt_tone_rate="${arg#*=}" ;;
sq.tone_amp=*) opt_tone_amp="${arg#*=}" ;;
sq.tone_dev=*) opt_tone_dev="${arg#*=}" ;;
sq.tone_card=*) opt_tone_card="${arg#*=}" ;;
sq.gpu=*) opt_gpu="${arg#*=}" ;;
sq.screen=*) opt_screen="${arg#*=}" ;;
sq.settle=*) opt_settle="${arg#*=}" ;;
sq.debug=*) opt_debug="${arg#*=}" ;;
esac
done
# fd 3: the log device (serial), falling back to the console, then to nowhere
if (exec 3> "/dev/${opt_log}") 2> /dev/null; then
exec 3> "/dev/${opt_log}"
stty -F "/dev/${opt_log}" raw -echo 2> /dev/null
elif (exec 3> /dev/console) 2> /dev/null; then
exec 3> /dev/console
else
exec 3> /dev/null
fi
uptime_s() {
cut -d' ' -f1 /proc/uptime
}
log() {
echo "SQGUEST $*" >&3
}
screen() {
echo "$*" > /dev/tty1 2> /dev/null
}
# Keep device names safe for the hexdump format string and single-line parsing.
sanitize() {
echo "$1" | sed 's/[^A-Za-z0-9 ._:\/()+,-]/_/g'
}
. /etc/sunshine-qemu-guest 2> /dev/null
log "BOOT kernel=$(uname -r) build=${build:-unknown} uptime=$(uptime_s)"
[ "${opt_debug}" = 1 ] || dmesg -n 1
# 1. drivers
if [ "${opt_gpu}" = 1 ]; then
modprobe virtio-gpu 2> /dev/null || log "WARN module=virtio-gpu load failed"
fi
for module in evdev psmouse virtio_input snd-hda-codec-generic snd-hda-intel virtio_snd; do
modprobe "${module}" 2> /dev/null || log "WARN module=${module} load failed"
done
input_count() {
ls -d /sys/class/input/event* 2> /dev/null | wc -l
}
pcm_count() {
ls /dev/snd/pcmC*D*p 2> /dev/null | wc -l
}
# PS/2 (serio) and HDA codec probing are asynchronous; virtio and PCI probing happen in modprobe.
unbound_count() {
n=0
for dev in /sys/bus/serio/devices/* /sys/bus/hdaudio/devices/*; do
[ -e "${dev}" ] && [ ! -e "${dev}/driver" ] && n=$((n + 1))
done
echo "${n}"
}
# Wait until every serio and HDA codec device has a driver and the device counts have stopped
# changing for a second, or sq.settle seconds.
last=""
stable=0
tries=$((opt_settle * 5))
while [ "${tries}" -gt 0 ]; do
now="$(input_count)/$(pcm_count)"
if [ "${now}" = "${last}" ] && [ "$(unbound_count)" = 0 ]; then
stable=$((stable + 1))
[ "${stable}" -ge 5 ] && break
else
stable=0
last="${now}"
fi
tries=$((tries - 1))
usleep 200000
done
log "DEVICES input=$(input_count) pcm=$(pcm_count) unbound=$(unbound_count) uptime=$(uptime_s)"
# 2. screen
clear > /dev/tty1 2> /dev/null
screen "sunshine-qemu Linux test guest ($(uname -r), build ${build:-unknown})"
if [ "${opt_tone}" = off ]; then
screen "Input events are logged to /dev/${opt_log}; no tone."
else
screen "Input events are logged to /dev/${opt_log}; tone: ${opt_tone} ${opt_tone_freq} Hz."
fi
# without a shell nothing reads tty1, so don't echo keystrokes into the banner or event lines
[ "${opt_screen}" = shell ] || stty -F /dev/tty1 -echo 2> /dev/null
screen ""
# 3. evdev logger
mkdir -p /run/evdev
start_logger() {
ev="$1"
sys="/sys/class/input/${ev}/device"
name="$(sanitize "$(cat "${sys}/name" 2> /dev/null)")"
: > "/run/evdev/${ev}"
log "DEVICE dev=${ev} bus=$(cat "${sys}/id/bustype") vendor=$(cat "${sys}/id/vendor") product=$(cat "${sys}/id/product") ev=$(tr ' ' ':' < "${sys}/capabilities/ev") key=$(tr ' ' ':' < "${sys}/capabilities/key") rel=$(tr ' ' ':' < "${sys}/capabilities/rel") abs=$(tr ' ' ':' < "${sys}/capabilities/abs") name=${name}"
# struct input_event on x86_64: s64 tv_sec, s64 tv_usec, u16 type, u16 code, s32 value.
# dd reads exactly one event per read(); evdev rejects the short readv() that stdio (hexdump) does.
{
dd if="/dev/input/${ev}" bs=24 status=none | hexdump -v -e "\"SQGUEST EVDEV dev=${ev} \" 1/8 \"time=%d.\" 1/8 \"%06d \" 1/2 \"type=%u \" 1/2 \"code=%u \" 1/4 \"value=%d name=${name}\\n\"" >&3
log "DEVICE_GONE dev=${ev}"
rm -f "/run/evdev/${ev}"
} &
# Don't report the device until the logger has it open, so no event is missed after READY.
tries=100
while [ "${tries}" -gt 0 ] && ! fuser "/dev/input/${ev}" > /dev/null 2>&1; do
tries=$((tries - 1))
usleep 20000
done
if [ "${opt_screen}" = events ]; then
dd if="/dev/input/${ev}" bs=24 status=none | hexdump -v -e "\"${ev} \" 1/8 \"%d.\" 1/8 \"%06d \" 1/2 \"type=%u \" 1/2 \"code=%u \" 1/4 \"value=%d\\n\"" > /dev/tty1 2> /dev/null &
fi
}
start_new_loggers() {
for path in /sys/class/input/event*; do
[ -e "${path}" ] || continue
ev="${path##*/}"
[ -e "/run/evdev/${ev}" ] || start_logger "${ev}"
done
}
evdev_state=off
if [ "${opt_evdev}" = 1 ]; then
start_new_loggers
# pick up devices that appear later (late PS/2 probing, device_add)
while :; do
sleep 1
start_new_loggers
done &
evdev_state=on
fi
# 4. tone
for card in /proc/asound/card[0-9]*; do
[ -d "${card}" ] || continue
index="${card##*card}"
log "AUDIO card=${index} id=$(cat "${card}/id") pcm=$(ls "${card}" | grep -c 'pcm.*p') name=$(sanitize "$(sed -n "s/^ *${index} \[[^]]*\]: //p" /proc/asound/cards)")"
# unmute and max every playback control, so QEMU doesn't apply a guest mute or attenuation
amixer -c "${index}" scontrols 2> /dev/null | sed -n "s/^Simple mixer control '\(.*\)',\([0-9]*\)$/\1,\2/p" |
while IFS= read -r control; do
amixer -q -c "${index}" sset "${control}" 100% unmute 2> /dev/null ||
amixer -q -c "${index}" sset "${control}" unmute 2> /dev/null
done
done
[ -n "${opt_tone_card}" ] && export ALSA_CARD="${opt_tone_card}"
tone_state=off
case "${opt_tone}" in
sine)
# The shortest whole-cycle loop of stereo s16le (rate / gcd(rate, freq) frames: 48 for
# 1000 Hz at 48 kHz), doubled up to at least one second, so the stream loops without a seam,
# the generation stays fast under TCG, and cat runs only about once a second.
gcd="${opt_tone_rate}"
rest="${opt_tone_freq}"
while [ "${rest}" -gt 0 ]; do
t=$((gcd % rest))
gcd="${rest}"
rest="${t}"
done
awk -v f="${opt_tone_freq}" -v r="${opt_tone_rate}" -v a="${opt_tone_amp}" -v n=$((opt_tone_rate / gcd)) 'BEGIN {
pi = atan2(0, -1)
for (i = 0; i < n; i++) {
v = int(a * sin(2 * pi * f * i / r))
if (v < 0) v += 65536
printf "%02x%02x%02x%02x", v % 256, int(v / 256), v % 256, int(v / 256)
if (i % 16 == 15) printf "\n"
}
}' | xxd -r -p > /run/tone.raw
while [ "$(wc -c < /run/tone.raw)" -lt $((opt_tone_rate * 4)) ]; do
cat /run/tone.raw /run/tone.raw > /run/tone.tmp
mv /run/tone.tmp /run/tone.raw
done
while :; do
while cat /run/tone.raw; do :; done |
aplay -q -D "${opt_tone_dev}" -t raw -f S16_LE -r "${opt_tone_rate}" -c 2 > /run/tone.log 2>&1
log "TONE exited method=sine uptime=$(uptime_s) error=$(tail -n 1 /run/tone.log | tr ' ' '_')"
sleep 1
done &
tone_state=starting
;;
speaker-test)
while :; do
speaker-test -D "${opt_tone_dev}" -c 2 -r "${opt_tone_rate}" -F S16_LE -t sine -f "${opt_tone_freq}" -l 0 > /run/tone.log 2>&1
log "TONE exited method=speaker-test uptime=$(uptime_s)"
sleep 1
done &
tone_state=starting
;;
esac
if [ "${tone_state}" = starting ]; then
tone_state=error
tries=50
while [ "${tries}" -gt 0 ]; do
running="$(grep -l 'state: RUNNING' /proc/asound/card*/pcm*p/sub*/status 2> /dev/null | head -n 1)"
if [ -n "${running}" ]; then
tone_state=running
break
fi
tries=$((tries - 1))
usleep 100000
done
if [ "${tone_state}" = running ]; then
# /proc/asound/cardN/pcmMp/subK/status
set -- $(echo "${running}" | sed 's#/proc/asound/card\([0-9]*\)/pcm\([0-9]*\)p/sub\([0-9]*\)/status#\1 \2 \3#')
params="$(tr '\n' ' ' < "/proc/asound/card$1/pcm$2p/sub$3/hw_params" | sed 's/ ([^)]*)//g; s/: /=/g; s/ */ /g; s/ *$//')"
log "TONE running method=${opt_tone} freq=${opt_tone_freq} dev=${opt_tone_dev} card=$1 pcm=$2 ${params} uptime=$(uptime_s)"
else
log "TONE error method=${opt_tone} reason=no-running-pcm pcm_devices=$(pcm_count) log=$(tail -n 1 /run/tone.log 2> /dev/null | tr ' ' '_')"
fi
fi
log "READY evdev=${evdev_state} input=$(ls /run/evdev 2> /dev/null | wc -l) tone=${tone_state} uptime=$(uptime_s)"
screen "READY (evdev ${evdev_state}, tone ${tone_state})"
# 5. tty1
case "${opt_screen}" in
shell)
while :; do
setsid -c sh -c 'cd /; export PS1="guest# "; exec sh -i' < /dev/tty1 > /dev/tty1 2>&1
sleep 1
done &
;;
esac
# PID 1 must never exit
while :; do
sleep 3600
done
tests/e2e/qemu/guest/linux/README.md +226 −0
@@ -1,0 +1,226 @@
# Linux test guest
A small Linux guest for the audio (#4) and input (#5) end-to-end tests. It has no guest agent and
needs no network at runtime. When it boots, it:
- logs every evdev event from every input device to the serial console, one line per event,
- plays a continuous 1000 Hz sine tone to the default ALSA device,
- prints `SQGUEST READY` on the serial console once both are running,
- shows a banner and a root shell on the framebuffer console, so typing is visible in a stream.
It is an Alpine Linux `virt` kernel with a custom initramfs (busybox, musl, alsa-lib, `aplay`,
`speaker-test`, and a few kernel modules). The build is a script: nothing binary is committed.
## Files
| File | What it is |
|-----------------|--------------------------------------------------------------------------------------------|
| `build.sh` | Downloads the pinned Alpine files (SHA-256 checked) and builds `vmlinuz` + `initramfs.gz` |
| `init` | The guest's `/init` (busybox `sh`): drivers, evdev logger, tone, ready marker, console |
| `run.sh` | Boots the guest through `../../run_vm.sh` (private bus, `-display dbus`), serial to a file |
| `wait_ready.sh` | Waits for `SQGUEST READY` in a serial log |
| `selftest.sh` | Tests the guest itself (not Sunshine) on D-Bus, QMP, PS/2, wav and D-Bus audio |
## Quick start
```bash
tests/e2e/qemu/guest/linux/build.sh # prints the output directory, reused when up to date
work=$(mktemp -d)
QEMU=~/.cache/sunshine-qemu/qemu-install/bin/qemu-system-x86_64 \
tests/e2e/qemu/guest/linux/run.sh "$work" # prints unix:path=$work/bus.sock
tests/e2e/qemu/guest/linux/wait_ready.sh "$work/serial.log" 30
grep '^SQGUEST EVDEV ' "$work/serial.log"
kill "$(cat "$work/qemu.pid")" "$(cat "$work/dbus.pid")"
tests/e2e/qemu/guest/linux/selftest.sh # all self-test cases; QEMU=..., VM_ACCEL=tcg work too
```
`build.sh` writes to `~/.cache/sunshine-qemu/e2e/linux-guest-<hash of build.sh and init>/`
(`E2E_CACHE` changes the root), with downloads in `~/.cache/sunshine-qemu/e2e/linux-guest-downloads/`.
It needs `bash`, `curl`, `sha256sum`, `tar`, `gzip` and `unsquashfs` (squashfs-tools); no root and
no `cpio` (it runs the guest's own busybox `cpio` through the guest's musl loader). The first build
downloads about 40 MB and takes a few seconds after that. The output is byte-for-byte reproducible:
two builds, including one with a different umask, produced the same `initramfs.gz`.
Output directory:
| File | Use |
|----------------|---------------------------------------------------------------------------------|
| `vmlinuz` | `-kernel` |
| `initramfs.gz` | `-initrd` |
| `cmdline` | default `-append` (`console=ttyS0 loglevel=4`); add `sq.*` options after it |
| `disk.img` | 1 MiB blank raw disk, only because `run_vm.sh` always passes a `-drive` |
## Booting it from a harness
`run.sh <work-dir> [extra qemu args]` calls `run_vm.sh` with the guest disk plus
`-kernel`, `-initrd`, `-append "$(cat cmdline) $GUEST_APPEND"` and `-serial file:<work-dir>/serial.log`.
Everything `run_vm.sh` sets up stays the same: a private `dbus-daemon`, `-display dbus`,
`-audiodev dbus,id=snd0`, `intel-hda` + `hda-output`, `virtio-vga`, `virtio-tablet-pci`,
`virtio-keyboard-pci`, and QMP on `<work-dir>/qmp.sock`. `GUEST_DIR` uses a prebuilt output directory.
Without `run.sh`, pass the same QEMU arguments yourself:
```bash
guest=$(tests/e2e/qemu/guest/linux/build.sh)
qemu-system-x86_64 -accel kvm -m 128 -nodefaults \
-kernel "$guest/vmlinuz" -initrd "$guest/initramfs.gz" -append "$(cat "$guest/cmdline")" \
-device virtio-vga -display dbus,addr="$bus",audiodev=snd0 \
-audiodev dbus,id=snd0 -device intel-hda -device hda-output,audiodev=snd0 \
-device virtio-tablet-pci -device virtio-keyboard-pci \
-serial file:serial.log # or: -chardev file,id=s0,path=serial.log -serial chardev:s0
```
Tested alternatives: `-device virtio-sound-pci,audiodev=snd0` instead of `intel-hda` +
`hda-output`; no virtio input devices (PS/2 keyboard and mouse only); the log on a virtio console
(`-device virtio-serial-pci -chardev file,id=hvc,path=log -device virtconsole,chardev=hvc` and
`sq.log=hvc0`). 128 MiB of RAM is enough.
> [!IMPORTANT]
> QEMU exports `/org/qemu/Display1/Audio` only when the display is tied to the audio backend:
> `-display dbus,addr=...,audiodev=snd0`. With `run_vm.sh` as it is (`-display dbus,addr=...` only),
> QEMU 11.1.1 and 8.2.2 both have no Audio object on the bus, so `RegisterOutListener` fails. The
> audio E2E test needs `audiodev=snd0` added to `run_vm.sh`'s `-display` option. `selftest.sh`
> checks D-Bus audio in its own `dbus-audio` case until then.
## Serial protocol
Every line the guest writes starts with `SQGUEST `, ends with `\n` (no `\r`), and uses
`key=value` fields separated by single spaces. Fields that can contain spaces (`name=`) are always
last. Device names are sanitized to `[A-Za-z0-9 ._:/()+,-]` (anything else becomes `_`). Kernel
messages are off after `/init` starts (`dmesg -n 1`), but a harness should still ignore lines
without the prefix.
| Line | Meaning |
|------|---------|
| `SQGUEST BOOT kernel=6.18.35-0-virt build=<hash> uptime=0.37` | `/init` started |
| `SQGUEST WARN module=<name> load failed` | a kernel module didn't load |
| `SQGUEST DEVICES input=5 pcm=1 unbound=0 uptime=1.65` | driver probing is done (count of evdev nodes, playback PCMs, serio/HDA devices without a driver) |
| `SQGUEST DEVICE dev=event3 bus=0006 vendor=0627 product=0003 ev=f key=... rel=100 abs=3 name=QEMU Virtio Tablet` | an input device; its logger has the device open when this is printed. `ev`/`key`/`rel`/`abs` are the sysfs capability bitmaps (hex, words joined with `:`) |
| `SQGUEST DEVICE_GONE dev=event3` | the device went away (its logger exited) |
| `SQGUEST AUDIO card=0 id=Intel pcm=1 name=HDA-Intel - HDA Intel` | an ALSA card; all its playback mixer controls were set to 100% and unmuted |
| `SQGUEST TONE running method=sine freq=1000 dev=default card=0 pcm=0 access=... format=S16_LE subformat=STD channels=2 rate=48000 period_size=1024 buffer_size=16384 uptime=2.16` | a playback substream is in the `RUNNING` state (`hw_params` of that substream follow `pcm=`) |
| `SQGUEST TONE error method=sine reason=no-running-pcm pcm_devices=0 log=<last aplay line>` | no substream reached `RUNNING` within 5 s |
| `SQGUEST TONE exited method=sine uptime=... error=...` | the player exited; it is restarted after 1 s |
| `SQGUEST READY evdev=on input=5 tone=running uptime=2.10` | loggers and tone are up. `evdev=on\|off`, `tone=running\|error\|off` |
| `SQGUEST EVDEV dev=event4 time=1789251250.565528 type=1 code=30 value=1 name=QEMU Virtio Keyboard` | one `struct input_event` |
`EVDEV` lines carry the raw event: `time` is the kernel event timestamp (seconds.microseconds,
`CLOCK_REALTIME` of the guest), `type`, `code` and `value` are decimal (`value` is signed). All
events are logged, including `EV_SYN` (`type=0 code=0 value=0`) after each packet, and `EV_MSC`.
Each device has its own logger process, so lines of one device are in order but lines of
different devices can interleave out of order under load; sort by `time` if the order across
devices matters. Devices that appear after `READY` (hotplug, slow PS/2 probing) are picked up within
a second and announced with a `DEVICE` line.
A regex for event lines:
```text
^SQGUEST EVDEV dev=(event[0-9]+) time=([0-9]+)\.([0-9]{6}) type=([0-9]+) code=([0-9]+) value=(-?[0-9]+) name=(.*)$
```
### Linux codes a test will look for
| Input | evdev line |
|-------|------------|
| key `a` | `type=1 code=30` (`KEY_A`), `value=1` press, `0` release, `2` autorepeat |
| left Shift | `type=1 code=42` (`KEY_LEFTSHIFT`) |
| mouse buttons left/right/middle/side/extra | `type=1 code=272/273/274/275/276` (`BTN_LEFT` ...) |
| relative motion | `type=2 code=0` (`REL_X`), `code=1` (`REL_Y`) |
| wheel | `type=2 code=8` (`REL_WHEEL`, `+1` up, `-1` down); `code=6` is `REL_HWHEEL` |
| absolute position | `type=3 code=0` (`ABS_X`), `code=1` (`ABS_Y`) |
QEMU's D-Bus `Keyboard.Press` takes QEMU qnums (`a` = `0x1e`, left Shift = `0x2a`).
`Mouse.SetAbsPosition(x, y)` becomes `ABS_X = x * 32767 / Width` (integer division) on the virtio
tablet: `640,200` on a 1280x800 console logged `16383,8191`.
### Which device gets the event
QEMU sends each event to one device, so match on the event, not on a fixed `eventN`:
| Guest devices | Keyboard events land on | Mouse |
|---------------|-------------------------|-------|
| `run_vm.sh` set (virtio tablet + keyboard) | `QEMU Virtio Keyboard` | `QEMU Virtio Tablet` (absolute, `IsAbsolute=true`), wheel as `REL_WHEEL` on the tablet |
| no virtio input, `-machine pc,vmport=off` | `AT Translated Set 2 keyboard` | `ImExPS/2 Generic Explorer Mouse` (relative, `IsAbsolute=false`) |
| no virtio input, default `vmport` under KVM | `AT Translated Set 2 keyboard` | `VirtualPS/2 VMware VMMouse` (two devices with that name: the absolute one, `abs=3`, gets `SetAbsPosition`; QEMU reports `IsAbsolute=true`) |
For a genuinely relative (PS/2) guest, use `-machine pc,vmport=off`. Under TCG the guest's VMMouse
probe didn't succeed in our runs, so it came up as `ImExPS/2 Generic Explorer Mouse` either way.
## Audio
With `sq.tone=sine` (default) the guest writes one whole-cycle period of a sine (48 frames for
1000 Hz at 48 kHz), repeats it to one second, and loops that file into
`aplay -D default -t raw -f S16_LE -r 48000 -c 2`, so the stream has no seams. The amplitude is
16384 (-6 dBFS) on both channels. `sq.tone=speaker-test` runs
`speaker-test -D default -c 2 -r 48000 -F S16_LE -t sine -f 1000 -l 0` instead; it alternates
between the left and right channel, so each channel is silent half of the time.
ALSA `default` is the first card (`sq.tone_card=` picks another by index or id). QEMU resamples to
its audio backend's rate: a D-Bus `AudioOutListener` receives `Init bits=16 signed=true float=false
freq=44100 channels=2`, and 1000 Hz at an RMS of 11568 (16384/sqrt 2) with no silent 10 ms block.
## Kernel command line options
| Option | Default | Effect |
|--------|---------|--------|
| `sq.log=` | `ttyS0` | log device under `/dev` (`hvc0` for a virtio console); falls back to `/dev/console` |
| `sq.evdev=` | `1` | `0` disables the evdev logger |
| `sq.tone=` | `sine` | `sine`, `speaker-test` or `off` |
| `sq.tone_freq=` | `1000` | tone frequency in Hz (integer) |
| `sq.tone_rate=` | `48000` | PCM rate |
| `sq.tone_amp=` | `16384` | `sine` amplitude (32767 is full scale) |
| `sq.tone_dev=` | `default` | ALSA PCM |
| `sq.tone_card=` | first card | card index or id for ALSA `default` (`ALSA_CARD`) |
| `sq.gpu=` | `1` | `0` skips `virtio-gpu`; the console stays in 720x400 VGA text mode |
| `sq.screen=` | `shell` | `shell`: banner and root shell on tty1; `events`: event lines on tty1; `banner`: banner only |
| `sq.settle=` | `20` | upper bound in seconds for input and sound driver probing |
| `sq.debug=` | `0` | `1` keeps kernel messages on the console |
With `virtio-gpu` loaded (default) the console is 1280x800, virtio-gpu's default mode.
## Measurements (this host: WSL2, 20 cores, QEMU 11.1.1 and 8.2.2)
| Run | `READY` (guest uptime) |
|-----|------------------------|
| KVM, `run_vm.sh` devices | 1.7 to 3.4 s (the upper end with a host load average of 20) |
| KVM, PS/2 only | 1.5 to 2.2 s |
| TCG, `run_vm.sh` devices | 6 s with little host load, up to 28 s while Sunshine was building in parallel |
`wait_ready.sh` defaults to 30 s; use 300 s for TCG. `selftest.sh` uses those values.
## Updating the pinned versions
`build.sh` pins Alpine 3.24.1: `vmlinuz-virt` and `modloop-virt` from
`releases/x86_64/netboot-3.24.1/`, `alpine-minirootfs-3.24.1-x86_64.tar.gz` (its SHA-256 matches
Alpine's published `latest-releases.yaml`), and `alsa-lib-1.2.15.3-r0.apk` and
`alsa-utils-1.2.15.2-r1.apk` from `v3.24/main`. Release files stay on the mirrors; the `main`
repository only keeps the latest build of each package, so the alsa packages will eventually 404.
The build then stops with `download failed` and a hint. To update:
1. Look up the current versions in `https://dl-cdn.alpinelinux.org/alpine/v3.24/main/x86_64/`
(or move everything to a newer Alpine release; `kernel_version` must match the modloop).
2. Download the new files, run `sha256sum`, and update the names and checksums in `build.sh`.
3. Run `selftest.sh`. Changing `build.sh` changes the output hash, so harnesses rebuild.
Existing downloads in the cache keep working, and `ALPINE_MIRROR` points the build at another
mirror or an archive.
## Self-test
`selftest.sh` checks the guest, not Sunshine. Cases (`SELFTEST_CASES`):
- `harness`: boots with `run.sh`, waits for `READY`, checks the device lines and the tone, sends
`a`, Shift+`a`, an absolute move, a left click and a wheel step through D-Bus `Keyboard`/`Mouse`,
sends `b` through QMP `input-send-event`, checks that the last event line is the trailing
`SYN_REPORT` (no lag in the logger), and checks that a QMP `screendump` shows console text.
- `ps2`: `-machine pc,vmport=off` without virtio input, serial through `-chardev`: D-Bus keyboard,
`RelMotion(10, -5)`, right click, `IsAbsolute=false`, `sq.tone=off`.
- `dbus-audio`: `-display dbus,audiodev=snd0`; a Python/Gio `AudioOutListener` registered with
`RegisterOutListener` checks the tone (needs `python3-gi`, skipped without it).
- `audio-hda` and `audio-virtio`: `-audiodev wav` with `intel-hda` + `hda-output` and with
`virtio-sound-pci`: dominant frequency within 10 Hz, median RMS, no silent 10 ms blocks (gaps are
allowed under TCG).
It needs `dbus-daemon`, `gdbus` and `python3`.
tests/e2e/qemu/guest/linux/run.sh +29 −0
@@ -1,0 +1,29 @@
#!/usr/bin/env bash
# Boot the Linux test guest through run_vm.sh: private dbus-daemon, -display dbus, -audiodev dbus,
# intel-hda + hda-output, virtio-vga, virtio-tablet-pci, virtio-keyboard-pci (plus the built-in PS/2
# keyboard and mouse), and the serial console written to <work-dir>/serial.log.
#
# Usage: run.sh <work-dir> [extra qemu args...]
#
# Prints the D-Bus address on stdout, like run_vm.sh. It doesn't wait for the guest; use
# wait_ready.sh <work-dir>/serial.log for that.
#
# Environment (plus everything run_vm.sh reads: QEMU, VM_NAME, VM_ACCEL, VM_START_WAIT):
# GUEST_DIR a build.sh output directory (default: build or reuse the cached one)
# GUEST_APPEND extra kernel command line options, e.g. "sq.tone=off sq.tone_freq=440"
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
work="${1:?usage: run.sh <work-dir> [qemu args...]}"
shift
guest="${GUEST_DIR:-$("${script_dir}/build.sh")}"
mkdir -p "${work}"
rm -f "${work}/serial.log"
exec "${script_dir}/../../run_vm.sh" "${work}" "${guest}/disk.img" \
-kernel "${guest}/vmlinuz" \
-initrd "${guest}/initramfs.gz" \
-append "$(cat "${guest}/cmdline") ${GUEST_APPEND:-}" \
-serial file:"${work}/serial.log" \
"$@"
tests/e2e/qemu/guest/linux/selftest.sh +502 −0
@@ -1,0 +1,502 @@
#!/usr/bin/env bash
# Self-test of the Linux test guest (not of Sunshine): boots it the way the E2E harness does and
# checks the ready marker, the evdev log for input injected through QEMU's D-Bus display and QMP,
# the framebuffer console, and the guest's sine tone.
#
# Usage: selftest.sh
#
# Cases (SELFTEST_CASES, space separated, default: all of them):
# harness run.sh: -display dbus on a private bus, -audiodev dbus, intel-hda + hda-output,
# virtio-vga, virtio-tablet-pci, virtio-keyboard-pci. Checks D-Bus Keyboard/Mouse
# (absolute) input, QMP input-send-event, a QMP screendump of the console, and
# the D-Bus audio tone if run_vm.sh exports the Audio object.
# ps2 no virtio input devices and vmport=off, so the guest mouse is a relative PS/2
# mouse: D-Bus Keyboard and Mouse RelMotion/Press. Serial through -chardev.
# dbus-audio the harness devices with -display dbus,audiodev=snd0, which exports
# /org/qemu/Display1/Audio: the tone through a D-Bus AudioOutListener (python3-gi).
# audio-hda intel-hda + hda-output into -audiodev wav: dominant frequency, level, no gaps.
# audio-virtio the same with virtio-sound-pci.
#
# Environment:
# QEMU QEMU binary (default: qemu-system-x86_64)
# VM_ACCEL kvm or tcg (default: kvm when /dev/kvm is usable)
# READY_TIMEOUT seconds to wait for SQGUEST READY (default: 30 with KVM, 300 with TCG)
# GUEST_DIR prebuilt build.sh output (default: build or reuse the cached one)
# SELFTEST_KEEP 1 keeps the work directory
#
# Host tools: dbus-daemon, gdbus, python3 (python3-gi for the D-Bus audio check).
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
qemu="${QEMU:-qemu-system-x86_64}"
if [[ -z "${VM_ACCEL:-}" ]]; then
if [[ -r /dev/kvm && -w /dev/kvm ]]; then
VM_ACCEL=kvm
else
VM_ACCEL=tcg
fi
fi
export VM_ACCEL QEMU="${qemu}"
if [[ "${VM_ACCEL}" == kvm ]]; then
ready_timeout="${READY_TIMEOUT:-30}"
event_timeout=5
max_gaps=0
else
ready_timeout="${READY_TIMEOUT:-300}"
event_timeout=30
# a TCG guest on a busy host can underrun; still require the frequency and level
max_gaps=1000000
fi
cases="${SELFTEST_CASES:-harness ps2 dbus-audio audio-hda audio-virtio}"
GUEST_DIR="${GUEST_DIR:-$("${script_dir}/build.sh")}"
export GUEST_DIR
cmdline="$(cat "${GUEST_DIR}/cmdline")"
work="$(mktemp -d "${TMPDIR:-/tmp}/sq-guest-selftest.XXXXXX")"
failures=0
stop_vm() {
local dir="$1" pidfile pid
for pidfile in "${dir}/qemu.pid" "${dir}/dbus.pid"; do
[[ -f "${pidfile}" ]] || continue
pid="$(cat "${pidfile}")"
kill "${pid}" 2> /dev/null || true
for _ in $(seq 1 50); do
kill -0 "${pid}" 2> /dev/null || break
sleep 0.1
done
kill -9 "${pid}" 2> /dev/null || true
rm -f "${pidfile}"
done
}
cleanup() {
for dir in "${work}"/*/; do
[[ -d "${dir}" ]] && stop_vm "${dir%/}"
done
if [[ ${failures} == 0 && "${SELFTEST_KEEP:-0}" != 1 ]]; then
rm -rf "${work}"
else
echo "selftest: work directory kept at ${work}" >&2
fi
}
trap cleanup EXIT
pass() {
echo "selftest: PASS ${current}: $*"
}
fail() {
echo "selftest: FAIL ${current}: $*" >&2
failures=$((failures + 1))
}
# expect <log> <extended regex> <description>: wait for a matching serial line
expect() {
local log="$1" regex="$2" what="$3" line deadline=$((SECONDS + event_timeout))
while ((SECONDS < deadline)); do
if line="$(tr -d '\r' < "${log}" | grep -a -m1 -E "${regex}")"; then
pass "${what}: ${line}"
return 0
fi
sleep 0.1
done
fail "${what}: no line matching /${regex}/"
}
wait_ready() {
local log="$1" start="$2" line
if line="$("${script_dir}/wait_ready.sh" "${log}" "${ready_timeout}")"; then
pass "ready after $((SECONDS - start)) s (host) / guest ${line##*uptime=} s: ${line}"
return 0
fi
fail "no ready marker within ${ready_timeout} s"
return 1
}
# console <bus> <method> <args...>: call a method on /org/qemu/Display1/Console_0
console() {
local bus="$1" method="$2"
shift 2
gdbus call --address "${bus}" --dest org.qemu --object-path /org/qemu/Display1/Console_0 \
--method "${method}" "$@" > /dev/null || fail "D-Bus ${method} $*"
}
console_prop() {
local bus="$1" iface="$2" prop="$3"
gdbus call --address "${bus}" --dest org.qemu --object-path /org/qemu/Display1/Console_0 \
--method org.freedesktop.DBus.Properties.Get "${iface}" "${prop}" | sed -E 's/^\(<(uint32 |)(.*)>,\)$/\2/'
}
# qmp <socket> <json command>...: run QMP commands, print their replies
qmp() {
python3 - "$@" << 'EOF'
import json, socket, sys
sock = socket.socket(socket.AF_UNIX)
sock.connect(sys.argv[1])
f = sock.makefile("rw")
json.loads(f.readline())
for command in ['{"execute": "qmp_capabilities"}'] + sys.argv[2:]:
f.write(command + "\n")
f.flush()
while True:
reply = json.loads(f.readline())
if "return" in reply or "error" in reply:
break
if "error" in reply:
sys.exit("qmp error: %s for %s" % (reply["error"], command))
print(json.dumps(reply["return"]))
EOF
}
qmp_key() {
echo "{\"execute\": \"input-send-event\", \"arguments\": {\"events\": [{\"type\": \"key\", \"data\": {\"down\": $2, \"key\": {\"type\": \"qcode\", \"data\": \"$1\"}}}]}}"
}
# tone_check <wav|dbus> <file|bus> <seconds> <expected Hz>: dominant frequency, level and gaps
tone_check() {
python3 - "$@" "${max_gaps}" << 'EOF'
import math, struct, sys, time
mode, source, seconds, expected, max_gaps = sys.argv[1], sys.argv[2], float(sys.argv[3]), float(sys.argv[4]), int(sys.argv[5])
def analyse(pcm, rate, channels, what):
frames = len(pcm) // (2 * channels)
samples = struct.unpack("<%dh" % (frames * channels), pcm[: frames * 2 * channels])
left = samples[0::channels]
block = rate // 100
rms = [math.sqrt(sum(x * x for x in left[i : i + block]) / block) for i in range(0, frames - block + 1, block)]
start = next((i for i, v in enumerate(rms) if v > 1000), None)
if start is None:
sys.exit("%s: no tone in %.2f s of audio" % (what, frames / rate))
active = rms[start:]
gaps = sum(1 for v in active if v < 1000)
window = left[start * block : start * block + rate]
def goertzel(freq):
k = 2 * math.cos(2 * math.pi * freq / rate)
s1 = s2 = 0.0
for x in window:
s1, s2 = x + k * s1 - s2, s1
return math.sqrt(max(s1 * s1 + s2 * s2 - k * s1 * s2, 0))
dominant = max(range(50, rate // 2, 10), key=goertzel)
level = sorted(active)[len(active) // 2]
print("%s: %d Hz stereo=%d, %.2f s, tone from %.2f s, dominant %d Hz, median rms %.0f, silent 10 ms blocks after start: %d"
% (what, rate, channels, frames / rate, start / 100, dominant, level, gaps))
if abs(dominant - expected) > 10 or gaps > max_gaps or level < 3000:
sys.exit("%s: tone check failed" % what)
if mode == "wav":
data = open(source, "rb").read()
channels, rate, bits = struct.unpack_from("<H", data, 22)[0], struct.unpack_from("<I", data, 24)[0], struct.unpack_from("<H", data, 34)[0]
if bits != 16:
sys.exit("unexpected wav format: %d bits" % bits)
# QEMU fixes the RIFF sizes only on a clean exit; read everything after the 44-byte header
analyse(data[44:], rate, channels, "wav")
sys.exit(0)
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
XML = """<node><interface name="org.qemu.Display1.AudioOutListener">
<method name="Init"><arg name="id" type="t" direction="in"/><arg name="bits" type="y" direction="in"/>
<arg name="is_signed" type="b" direction="in"/><arg name="is_float" type="b" direction="in"/>
<arg name="freq" type="u" direction="in"/><arg name="nchannels" type="y" direction="in"/>
<arg name="bytes_per_frame" type="u" direction="in"/><arg name="bytes_per_second" type="u" direction="in"/>
<arg name="be" type="b" direction="in"/></method>
<method name="Fini"><arg name="id" type="t" direction="in"/></method>
<method name="SetEnabled"><arg name="id" type="t" direction="in"/><arg name="enabled" type="b" direction="in"/></method>
<method name="SetVolume"><arg name="id" type="t" direction="in"/><arg name="mute" type="b" direction="in"/><arg name="volume" type="ay" direction="in"/></method>
<method name="Write"><arg name="id" type="t" direction="in"/><arg name="data" type="ay" direction="in"/></method>
<property name="Interfaces" type="as" access="read"/>
</interface></node>"""
import socket
bus = Gio.DBusConnection.new_for_address_sync(
source, Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
ours, theirs = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
fds = Gio.UnixFDList.new()
index = fds.append(theirs.fileno())
bus.call_with_unix_fd_list_sync("org.qemu", "/org/qemu/Display1/Audio", "org.qemu.Display1.Audio", "RegisterOutListener",
GLib.Variant("(h)", (index,)), None, Gio.DBusCallFlags.NONE, 5000, fds, None)
theirs.close()
stream = Gio.Socket.new_from_fd(ours.detach()).connection_factory_create_connection()
peer = Gio.DBusConnection.new_sync(
stream, None, Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.DELAY_MESSAGE_PROCESSING, None, None)
streams = {}
first_write = [None]
def on_call(connection, sender, path, interface, method, params, invocation):
stream_id = params.get_child_value(0).get_uint64()
if method == "Init":
_, bits, signed, is_float, freq, nchannels, bpf, bps, be = params.unpack()
streams[stream_id] = {"bits": bits, "signed": signed, "float": is_float, "freq": freq, "channels": nchannels,
"be": be, "enabled": False, "data": bytearray()}
print("dbus: Init id=%d bits=%d signed=%s float=%s freq=%d channels=%d bytes_per_second=%d be=%s"
% (stream_id, bits, signed, is_float, freq, nchannels, bps, be))
elif method == "SetEnabled":
streams.setdefault(stream_id, {"data": bytearray()})["enabled"] = params.get_child_value(1).get_boolean()
elif method == "Write":
if first_write[0] is None:
first_write[0] = time.monotonic()
streams.setdefault(stream_id, {"data": bytearray()})["data"] += params.get_child_value(1).get_data_as_bytes().get_data()
invocation.return_value(None)
def on_get(connection, sender, path, interface, name):
return GLib.Variant("as", [])
peer.register_object(
"/org/qemu/Display1/AudioOutListener", Gio.DBusNodeInfo.new_for_xml(XML).interfaces[0], on_call, on_get, None)
peer.start_message_processing()
loop = GLib.MainLoop()
GLib.timeout_add(int(seconds * 1000), loop.quit)
loop.run()
active = [s for s in streams.values() if len(s["data"])]
if not active:
sys.exit("dbus: no Write calls within %.1f s (streams: %s)" % (seconds, {k: v.get("enabled") for k, v in streams.items()}))
s = max(active, key=lambda s: len(s["data"]))
if s.get("bits") != 16 or s.get("float") or s.get("be"):
sys.exit("dbus: unexpected PCM format %s" % {k: v for k, v in s.items() if k != "data"})
elapsed = time.monotonic() - first_write[0]
print("dbus: %d bytes in %.2f s since the first Write (%.0f bytes/s)" % (len(s["data"]), elapsed, len(s["data"]) / elapsed))
analyse(bytes(s["data"]), s["freq"], s["channels"], "dbus")
EOF
}
# boot_direct <dir> <qemu args...>: private dbus-daemon plus QEMU with the guest kernel (no run_vm.sh)
boot_direct() {
local dir="$1"
shift
mkdir -p "${dir}"
dbus-daemon --session --nofork --nopidfile --address="unix:path=${dir}/bus.sock" > "${dir}/dbus.log" 2>&1 &
echo $! > "${dir}/dbus.pid"
for _ in $(seq 1 100); do
[[ -S "${dir}/bus.sock" ]] && break
sleep 0.05
done
"${qemu}" -accel "${VM_ACCEL}" -m 128 -nodefaults \
-kernel "${GUEST_DIR}/vmlinuz" -initrd "${GUEST_DIR}/initramfs.gz" \
-qmp unix:"${dir}/qmp.sock",server=on,wait=off \
"$@" > "${dir}/qemu.log" 2>&1 &
echo $! > "${dir}/qemu.pid"
}
wait_bus_name() {
local bus="$1"
for _ in $(seq 1 300); do
if gdbus call --address "${bus}" --dest org.freedesktop.DBus --object-path /org/freedesktop/DBus \
--method org.freedesktop.DBus.NameHasOwner org.qemu 2> /dev/null | grep -q true; then
return 0
fi
sleep 0.1
done
return 1
}
case_harness() {
local dir="${work}/harness" bus log start width height is_abs x y
start=${SECONDS}
if ! bus="$("${script_dir}/run.sh" "${dir}")"; then
fail "run.sh failed"
return
fi
log="${dir}/serial.log"
wait_ready "${log}" "${start}" || return
expect "${log}" '^SQGUEST DEVICE .*name=QEMU Virtio Tablet$' "virtio tablet present"
expect "${log}" '^SQGUEST DEVICE .*name=QEMU Virtio Keyboard$' "virtio keyboard present"
expect "${log}" '^SQGUEST TONE running ' "tone running"
width="$(console_prop "${bus}" org.qemu.Display1.Console Width)"
height="$(console_prop "${bus}" org.qemu.Display1.Console Height)"
is_abs="$(console_prop "${bus}" org.qemu.Display1.Mouse IsAbsolute)"
pass "console ${width}x${height}, IsAbsolute=${is_abs}"
# "a", then Shift+"A", through org.qemu.Display1.Keyboard (QEMU qnum codes: a=0x1e, lshift=0x2a)
console "${bus}" org.qemu.Display1.Keyboard.Press 30
console "${bus}" org.qemu.Display1.Keyboard.Release 30
expect "${log}" '^SQGUEST EVDEV dev=event[0-9]+ time=[0-9.]+ type=1 code=30 value=1 name=' "D-Bus key a down"
expect "${log}" '^SQGUEST EVDEV dev=event[0-9]+ time=[0-9.]+ type=1 code=30 value=0 name=' "D-Bus key a up"
console "${bus}" org.qemu.Display1.Keyboard.Press 42
console "${bus}" org.qemu.Display1.Keyboard.Press 30
console "${bus}" org.qemu.Display1.Keyboard.Release 30
console "${bus}" org.qemu.Display1.Keyboard.Release 42
expect "${log}" '^SQGUEST EVDEV .* type=1 code=42 value=1 name=' "D-Bus left shift down"
expect "${log}" '^SQGUEST EVDEV .* type=1 code=42 value=0 name=' "D-Bus left shift up"
if [[ "$(tr -d '\r' < "${log}" | grep -a -c -E '^SQGUEST EVDEV .* type=1 code=30 value=1 name=')" == 2 ]]; then
pass "second a press logged"
else
fail "expected two KEY_A presses"
fi
# the logger's last event of a burst (SYN_REPORT) must not lag behind
sleep 0.5
if tr -d '\r' < "${log}" | grep -a -E '^SQGUEST EVDEV ' | tail -n 1 | grep -q ' type=0 code=0 value=0 '; then
pass "trailing SYN_REPORT flushed"
else
fail "last logged event isn't a SYN_REPORT: $(tr -d '\r' < "${log}" | grep -a '^SQGUEST EVDEV ' | tail -n 1)"
fi
# absolute mouse move, click and wheel through org.qemu.Display1.Mouse (tablet range 0..32767)
x=$((width / 2))
y=$((height / 4))
console "${bus}" org.qemu.Display1.Mouse.SetAbsPosition "${x}" "${y}"
console "${bus}" org.qemu.Display1.Mouse.Press 0
console "${bus}" org.qemu.Display1.Mouse.Release 0
console "${bus}" org.qemu.Display1.Mouse.Press 4
console "${bus}" org.qemu.Display1.Mouse.Release 4
expect "${log}" "^SQGUEST EVDEV .* type=3 code=0 value=$((x * 32767 / width)) name=QEMU Virtio Tablet$" "abs x ${x}"
expect "${log}" "^SQGUEST EVDEV .* type=3 code=1 value=$((y * 32767 / height)) name=QEMU Virtio Tablet$" "abs y ${y}"
expect "${log}" '^SQGUEST EVDEV .* type=1 code=272 value=1 name=QEMU Virtio Tablet$' "left button down"
expect "${log}" '^SQGUEST EVDEV .* type=1 code=272 value=0 name=QEMU Virtio Tablet$' "left button up"
expect "${log}" '^SQGUEST EVDEV .* type=2 code=8 value=-1 name=QEMU Virtio Tablet$' "wheel down"
# QMP input-send-event as a second injection path
if qmp "${dir}/qmp.sock" "$(qmp_key b true)" "$(qmp_key b false)" > /dev/null; then
expect "${log}" '^SQGUEST EVDEV .* type=1 code=48 value=1 name=' "QMP key b down"
expect "${log}" '^SQGUEST EVDEV .* type=1 code=48 value=0 name=' "QMP key b up"
else
fail "QMP input-send-event"
fi
# the framebuffer console shows text: the screendump is neither empty nor a single color
if qmp "${dir}/qmp.sock" "{\"execute\": \"screendump\", \"arguments\": {\"filename\": \"${dir}/screen.ppm\"}}" > /dev/null &&
python3 - "${dir}/screen.ppm" << 'EOF'; then
import sys
data = open(sys.argv[1], "rb").read()
parts = data.split(b"\n", 3)
width, height = map(int, parts[1].split())
pixels = parts[3]
lit = sum(1 for i in range(0, len(pixels), 3) if pixels[i] + pixels[i + 1] + pixels[i + 2] > 200)
print("screendump %dx%d, %d lit pixels" % (width, height, lit))
sys.exit(0 if 200 < lit < width * height // 2 else 1)
EOF
pass "console text visible in screendump"
else
fail "console screendump has no text"
fi
if gdbus introspect --address "${bus}" --dest org.qemu --object-path /org/qemu/Display1/Audio 2> /dev/null |
grep -q 'interface org.qemu.Display1.Audio '; then
dbus_audio "${bus}"
else
echo "selftest: NOTE ${current}: run_vm.sh doesn't export /org/qemu/Display1/Audio (QEMU needs" \
"-display dbus,...,audiodev=snd0); D-Bus audio is checked in the dbus-audio case"
fi
stop_vm "${dir}"
}
dbus_audio() {
if ! python3 -c 'import gi; gi.require_version("Gio", "2.0")' 2> /dev/null; then
echo "selftest: SKIP ${current}: D-Bus audio check (python3-gi not installed)"
return
fi
if tone_check dbus "$1" 3 1000 | sed 's/^/selftest: /'; then
pass "D-Bus AudioOutListener receives a continuous 1000 Hz tone"
else
fail "D-Bus audio"
fi
}
# case_dbus_audio: the harness devices, with the audiodev attached to the D-Bus display
case_dbus_audio() {
local dir="${work}/dbus-audio" bus start
start=${SECONDS}
mkdir -p "${dir}"
bus="unix:path=${dir}/bus.sock"
boot_direct "${dir}" -device virtio-vga -display dbus,addr="${bus}",audiodev=snd0 \
-audiodev dbus,id=snd0 -device intel-hda -device hda-output,audiodev=snd0 \
-device virtio-tablet-pci -device virtio-keyboard-pci \
-serial file:"${dir}/serial.log" -append "${cmdline}"
if ! wait_bus_name "${bus}"; then
fail "QEMU didn't start: $(cat "${dir}/qemu.log")"
return
fi
wait_ready "${dir}/serial.log" "${start}" || return
dbus_audio "${bus}"
stop_vm "${dir}"
}
case_ps2() {
local dir="${work}/ps2" bus log start is_abs
start=${SECONDS}
mkdir -p "${dir}"
bus="unix:path=${dir}/bus.sock"
log="${dir}/serial.log"
boot_direct "${dir}" -machine pc,vmport=off -device virtio-vga -display dbus,addr="${bus}" \
-audiodev none,id=snd0 -device intel-hda -device hda-output,audiodev=snd0 \
-chardev file,id=serial0,path="${log}" -serial chardev:serial0 \
-append "${cmdline} sq.tone=off"
if ! wait_bus_name "${bus}"; then
fail "QEMU didn't start: $(cat "${dir}/qemu.log")"
return
fi
wait_ready "${log}" "${start}" || return
expect "${log}" '^SQGUEST DEVICE .*name=AT Translated Set 2 keyboard$' "PS/2 keyboard present"
expect "${log}" '^SQGUEST DEVICE .* rel=[0-9a-f]+ abs=0 name=ImExPS/2 Generic Explorer Mouse$' "PS/2 mouse present"
expect "${log}" '^SQGUEST READY .* tone=off ' "tone disabled by sq.tone=off"
is_abs="$(console_prop "${bus}" org.qemu.Display1.Mouse IsAbsolute)"
if [[ "${is_abs}" == false ]]; then
pass "IsAbsolute=false"
else
fail "IsAbsolute=${is_abs}, expected false"
fi
console "${bus}" org.qemu.Display1.Keyboard.Press 30
console "${bus}" org.qemu.Display1.Keyboard.Release 30
expect "${log}" '^SQGUEST EVDEV .* type=1 code=30 value=1 name=AT Translated Set 2 keyboard$' "PS/2 key a down"
expect "${log}" '^SQGUEST EVDEV .* type=1 code=30 value=0 name=AT Translated Set 2 keyboard$' "PS/2 key a up"
console "${bus}" org.qemu.Display1.Mouse.RelMotion 10 -- -5
expect "${log}" '^SQGUEST EVDEV .* type=2 code=0 value=10 name=ImExPS/2 Generic Explorer Mouse$' "rel x +10"
expect "${log}" '^SQGUEST EVDEV .* type=2 code=1 value=-5 name=ImExPS/2 Generic Explorer Mouse$' "rel y -5"
console "${bus}" org.qemu.Display1.Mouse.Press 2
console "${bus}" org.qemu.Display1.Mouse.Release 2
expect "${log}" '^SQGUEST EVDEV .* type=1 code=273 value=1 name=ImExPS/2 Generic Explorer Mouse$' "right button down"
expect "${log}" '^SQGUEST EVDEV .* type=1 code=273 value=0 name=ImExPS/2 Generic Explorer Mouse$' "right button up"
stop_vm "${dir}"
}
# case_audio <name> <audio device args...>
case_audio() {
local dir="${work}/$1" start
shift
start=${SECONDS}
mkdir -p "${dir}"
boot_direct "${dir}" -device virtio-vga -display none \
-audiodev wav,id=snd0,path="${dir}/tone.wav" "$@" \
-serial file:"${dir}/serial.log" -append "${cmdline}"
wait_ready "${dir}/serial.log" "${start}" || return
expect "${dir}/serial.log" '^SQGUEST TONE running method=sine freq=1000 ' "tone running"
sleep 3
qmp "${dir}/qmp.sock" '{"execute": "quit"}' > /dev/null 2>&1 || true
for _ in $(seq 1 50); do
kill -0 "$(cat "${dir}/qemu.pid")" 2> /dev/null || break
sleep 0.1
done
if tone_check wav "${dir}/tone.wav" 0 1000 | sed 's/^/selftest: /'; then
pass "continuous 1000 Hz tone in the wav capture"
else
fail "wav tone check"
fi
stop_vm "${dir}"
}
echo "selftest: QEMU $("${qemu}" --version | head -n 1), accel ${VM_ACCEL}, guest ${GUEST_DIR}"
for current in ${cases}; do
case "${current}" in
harness) case_harness ;;
ps2) case_ps2 ;;
dbus-audio) case_dbus_audio ;;
audio-hda) case_audio audio-hda -device intel-hda -device hda-output,audiodev=snd0 ;;
audio-virtio) case_audio audio-virtio -device virtio-sound-pci,audiodev=snd0 ;;
*) fail "unknown case" ;;
esac
done
if [[ ${failures} == 0 ]]; then
echo "selftest: ALL PASSED (${cases})"
else
echo "selftest: ${failures} FAILURE(S)" >&2
exit 1
fi
tests/e2e/qemu/guest/linux/wait_ready.sh +24 −0
@@ -1,0 +1,24 @@
#!/usr/bin/env bash
# Wait for the Linux test guest's ready marker on its serial log.
#
# Usage: wait_ready.sh <serial-log> [timeout-seconds]
#
# Prints the "SQGUEST READY ..." line and exits 0 once it appears. Exits 1 on timeout (default:
# 30 s; use 300 under TCG) and prints the end of the log to stderr.
set -euo pipefail
log="${1:?usage: wait_ready.sh <serial-log> [timeout-seconds]}"
timeout_s="${2:-30}"
deadline=$((SECONDS + timeout_s))
while ((SECONDS < deadline)); do
if [[ -f "${log}" ]] && line="$(grep -a -m1 '^SQGUEST READY ' "${log}")"; then
echo "${line}"
exit 0
fi
sleep 0.2
done
echo "wait_ready.sh: no SQGUEST READY in ${log} after ${timeout_s}s" >&2
tail -n 40 "${log}" >&2 2> /dev/null || true
exit 1