ref:4fe9720b259e7dd8e71a0ed120f02c4a1718abe9

feat(linux): render the VM at the size the client streams at

A VM that renders at one size while the client streams another costs real quality: a 1080p guest streamed at 720p and scaled back measures 20.1 dB PSNR against QEMU's own framebuffer, where matching sizes give 37.6 dB. Bitrate barely enters into it; the same guest gains nothing between 8 and 100 Mbps. Sunshine now reconfigures the VM's display for the session, as it already does for a physical one: it sends the client's size with QEMU's Console.SetUIInfo, the call a local viewer makes when its window is resized, and the guest's display device offers it as the preferred mode. The VM's own size is restored when the last client stops streaming, from a registry that holds the session alive, since the display that asked for the change is usually gone by then. Whether the guest takes the size is the guest's business: GNOME and KDE follow their preferred mode, and a guest with no display agent keeps its size and is scaled exactly as before. sq-follow-resolution shows what such an agent does, and the desktop test image runs it. REQ-CAP-007 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: 4fe9720b259e7dd8e71a0ed120f02c4a1718abe9
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-17 05:45
Parents: 433e726
20 files changed +617 -4
Type
.anvil.yml +11 −1
@@ -94,8 +94,18 @@
- name: gpu-vm
path: ci-artifacts/gpu-vm/*
- name: guest-follows-client-size
image: bare
runs_on: [linux, amd64]
timeout_seconds: 3600
depends_on: [build-and-unit-test-qemu-on]
run: scripts/ci/anvil-test.sh resize-vm
artifacts:
- name: follow-resolution
path: ci-artifacts/follow-resolution/*
- name: requirements
runs_on: [linux, amd64]
timeout_seconds: 600
depends_on: [build-and-unit-test-qemu-on, build-and-unit-test-qemu-off, lint, packaging, e2e, gpu-tests-in-vm, guest-follows-client-size]
depends_on: [build-and-unit-test-qemu-on, build-and-unit-test-qemu-off, lint, packaging, e2e, gpu-tests-in-vm]
run: scripts/ci/anvil-test.sh requirements
docs/configuration.md +32 −0
@@ -2295,6 +2295,38 @@
</tr>
</table>
### qemu_resize_guest
<table>
<tr>
<td>Description</td>
<td colspan="2">
Ask the VM to render at the size the client streams at, the way Sunshine reconfigures a physical
display for a session. Only used when [capture](#capture) is `qemu`.
<br><br>
Sunshine sends the size with QEMU's `Console.SetUIInfo`, and the guest's display device offers it as
its preferred mode. Whether the guest takes it is up to the guest: a desktop that follows its
preferred mode (GNOME and KDE do) resizes, and one without a display agent keeps its own size, which
costs nothing because the picture is scaled as before. The VM's own size is restored when the last
client stops streaming.
<br><br>
Turn this off to leave the VM's display alone, for example when someone also uses it locally at a
fixed size.
@note{Applies to Linux only.}
</td>
</tr>
<tr>
<td>Default</td>
<td colspan="2">`enabled`</td>
</tr>
<tr>
<td>Example</td>
<td colspan="2">@code{}
qemu_resize_guest = disabled
@endcode</td>
</tr>
</table>
### encoder
<table>
docs/qemu.md +23 −0
@@ -140,6 +140,29 @@
NVENC and Vulkan encode without a copy. QEMU must render on the encoder's GPU: set
[adapter_name](configuration.md#adapter_name) to the same render node for VAAPI.
## The VM's display size
A client streams at whatever size it asks for, and a VM that renders at a different one costs picture
quality: 1080p text scaled to 720p and back is barely readable. Sunshine therefore reconfigures the VM's
display for the session, as it does for a physical display.
On the first frame of a session Sunshine calls QEMU's `Console.SetUIInfo` with the client's size, the same
call a local viewer makes when its window is resized. QEMU passes it to the VM's display device, which offers
it as the connector's preferred mode. The VM's own size is restored when the last client stops streaming.
**The guest decides whether to take it.** A desktop that follows its preferred mode resizes itself: GNOME and
KDE do, and so does anything running a display agent. A guest with none keeps its size, and Sunshine scales
the picture as it did before — nothing breaks, the text is just softer. For a guest without one,
`tests/e2e/qemu/desktop/sq-follow-resolution` is a 30-line example of what such an agent does: watch the
preferred mode and apply it with `xrandr`.
Set [qemu_resize_guest](configuration.md#qemu_resize_guest) to `disabled` to leave the VM's display alone,
for instance when someone also uses that VM locally at a fixed size.
Matching sizes matters more than bitrate here. Measured against QEMU's own framebuffer, a 1080p guest
streamed at 1080p reaches 37.6 dB PSNR at 8 Mbps and gains nothing from 100 Mbps, while the same guest
streamed at 720p and scaled back up falls to 20.1 dB.
## Several VMs on one host
Every instance needs its own ports, name and state. Nothing else is shared, so configuration files are enough:
packaging/linux/qemu/example.conf +4 −0
@@ -20,6 +20,10 @@
# Name shown in Moonlight and advertised over mDNS (up to 63 bytes); keep it unique per host.
sunshine_name = example
# The VM renders at the size each client streams at, and gets its own size back when streaming stops.
# The guest applies it only if its desktop follows the preferred mode; see docs/qemu.md.
# qemu_resize_guest = enabled
# Per-VM state. Relative paths are resolved in the instance's state directory
# (/var/lib/sunshine-qemu/<vm>/sunshine or ~/.local/state/sunshine-qemu/<vm>/sunshine), so pairing,
# credentials and certificates are separate for every VM.
scripts/ci/anvil-test.sh +20 −0
@@ -13,6 +13,7 @@
# e2e <mode> end-to-end stream against a real QEMU guest: shm (bus transport), qmp, audio,
# input-absolute, input-relative, reset, dmabuf (skipped without a DRM render node)
# gpu-vm REQ-CAP-003's GPU tests inside a VM, for hosts whose GPU has no render node
# resize-vm the VM follows the client's size and gets its own back (REQ-CAP-006)
# requirements anvil requirement status for this repository
#
# Environment: CC/CXX (default gcc-14/g++-14), JOBS (default nproc), CI_ARTIFACTS (default ci-artifacts),
@@ -205,6 +206,24 @@
return "${status}"
}
cmd_resize_vm() {
section "the VM follows the client's display size"
local dir="cmake-build-ci-on"
[[ -x "${dir}/sunshine" ]] || { echo "build first: anvil-test.sh build on" >&2; exit 2; }
if [[ ! -x cmake-build-e2e-client/moonlight_e2e_client ]]; then
cmake -S tests/e2e/moonlight_client -B cmake-build-e2e-client -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
ninja -C cmake-build-e2e-client
fi
local status=0
SUNSHINE_BIN="${repo}/${dir}/sunshine" RESIZE_ARTIFACTS="${artifacts}/follow-resolution" \
tests/e2e/qemu/desktop/follow_resolution.sh || status=$?
if ((status == 77)); then
echo "SKIPPED: this runner has no desktop guest image"
return 0
fi
return "${status}"
}
cmd_requirements() {
section "requirement traceability"
local anvil="${ANVIL_BIN:-anvil}"
@@ -222,6 +241,7 @@
case "${command}" in
submodules | build | unit | packaging | lint | e2e | requirements) "cmd_${command}" "$@" ;;
gpu-vm) cmd_gpu_vm "$@" ;;
resize-vm) cmd_resize_vm "$@" ;;
*)
sed -n '2,20p' "${BASH_SOURCE[0]}"
exit 2
src/config.cpp +2 −0
@@ -781,6 +781,7 @@
{}, // adapter_name
{}, // output_name
{}, // qemu_dbus_address
true, // qemu_resize_guest
{
video_t::dd_t::config_option_e::disabled, // configuration_option
@@ -1700,6 +1701,7 @@
string_f(vars, "adapter_name", video.adapter_name);
string_f(vars, "output_name", video.output_name);
string_f(vars, "qemu_dbus_address", video.qemu_dbus_address);
bool_f(vars, "qemu_resize_guest", video.qemu_resize_guest);
generic_f(vars, "dd_configuration_option", video.dd.configuration_option, dd::config_option_from_view);
generic_f(vars, "dd_resolution_option", video.dd.resolution_option, dd::resolution_option_from_view);
src/config.h +1 −0
@@ -138,6 +138,7 @@
std::string adapter_name; ///< Display adapter name selected in configuration.
std::string output_name; ///< Display output name selected in configuration.
std::string qemu_dbus_address; ///< D-Bus address of the bus QEMU's display is on; empty uses the session bus.
bool qemu_resize_guest; ///< Ask the VM to render at the size the client streams at.
/**
* @brief Display-device integration settings.
src/platform/linux/misc.cpp +4 −1
@@ -66,6 +66,7 @@
#ifdef SUNSHINE_BUILD_QEMU
#include "qemu/input.h"
#include "qemu/session.h"
#endif
#ifdef __GNUC__
@@ -550,7 +551,9 @@
*/
void streaming_will_stop() {
#ifdef SUNSHINE_BUILD_QEMU
// the last client left: nothing it pressed may stay held in the VM, and its display goes back
// to the size the VM chose for itself
// the last client left: nothing it pressed may stay held in the VM
qemu::release_all_inputs();
qemu::restore_guest_size();
#endif
}
src/platform/linux/qemu/capture.cpp +26 −0
@@ -495,6 +495,30 @@
std::uint64_t imported_generation {0}; ///< Store generation of the buffer the encoder imported last.
bool reimport {false}; ///< Whether a black frame replaced the imported buffer.
};
/**
* @brief Ask the VM to render at the size the client streams at.
* @details Sunshine reconfigures a physical display for the session; a VM's display is reconfigured
* the same way, so the guest draws at the streamed size instead of Sunshine scaling its picture.
* The guest may keep its own size, which costs nothing: the stream is scaled as it was before.
*
* @param conn Console connection.
* @param config Stream configuration.
*/
void follow_client_size(const console_connection_t &conn, const video::config_t &config) {
if (!config::video.qemu_resize_guest || config.width <= 0 || config.height <= 0) {
return;
}
const auto width = (std::uint32_t) config.width;
const auto height = (std::uint32_t) config.height;
if (conn.console.width == width && conn.console.height == height) {
return;
}
BOOST_LOG(info) << "qemu: asking the VM for "sv << width << "x"sv << height
<< " to match the client; it renders at "sv << conn.console.width << "x"sv << conn.console.height;
if (!resize_console(conn.session, conn.console.id, width, height, conn.console.width, conn.console.height)) {
BOOST_LOG(warning) << "qemu: the VM didn't take the size hint; streaming its own size instead"sv;
}
}
} // namespace qemu
namespace platf {
@@ -557,6 +581,8 @@
if (!conn) {
return nullptr;
}
follow_client_size(*conn, config);
const auto console_id = conn->console.id;
const auto label = conn->console.label;
src/platform/linux/qemu/session.cpp +64 −0
@@ -653,6 +653,24 @@
return registration;
}
bool set_ui_info(std::uint32_t console_id, std::uint32_t width, std::uint32_t height) override {
auto it = std::ranges::find_if(console_proxies, [&](const auto &entry) {
return entry.first == console_id;
});
if (it == console_proxies.end() || !is_alive) {
return false;
}
GError *err = nullptr;
// width_mm and height_mm say "no physical size given"; the guest keeps its own DPI
const bool ok = qemu_dbus_display1_console_call_set_uiinfo_sync(it->second, 0, 0, 0, 0, width, height, G_DBUS_CALL_FLAGS_NONE, timeout_ms, nullptr, &err);
if (!ok) {
BOOST_LOG(warning) << "qemu: SetUIInfo("sv << width << "x"sv << height << ") failed: "sv << (err ? err->message : "unknown error");
g_clear_error(&err);
}
return ok;
}
std::shared_ptr<console_input_t> open_input(std::uint32_t console_id) override {
const bool known = std::ranges::any_of(console_proxies, [&](const auto &entry) {
return entry.first == console_id;
@@ -1595,6 +1613,52 @@
}
session->detect_touch_device();
return session;
}
namespace {
/**
* @brief A console whose size was changed for a client, and the size it had.
*/
struct resized_console_t {
std::shared_ptr<session_t> session; ///< Keeps the session alive until the size is restored.
std::uint32_t console_id {0}; ///< Console that was resized.
std::uint32_t width {0}; ///< Width the console had.
std::uint32_t height {0}; ///< Height the console had.
};
std::mutex resized_mutex; ///< Guards `resized_consoles`.
std::vector<resized_console_t> resized_consoles; ///< Consoles to put back when streaming stops.
} // namespace
bool resize_console(const std::shared_ptr<session_t> &session, std::uint32_t console_id, std::uint32_t width, std::uint32_t height, std::uint32_t original_width, std::uint32_t original_height) {
if (!session) {
return false;
}
if (original_width && original_height) {
std::scoped_lock lock {resized_mutex};
const bool known = std::ranges::any_of(resized_consoles, [&](const auto &entry) {
return entry.session == session && entry.console_id == console_id;
});
if (!known) {
resized_consoles.push_back({session, console_id, original_width, original_height});
}
}
return session->set_ui_info(console_id, width, height);
}
void restore_guest_size() {
std::vector<resized_console_t> consoles;
{
std::scoped_lock lock {resized_mutex};
consoles.swap(resized_consoles);
}
for (const auto &console : consoles) {
if (!console.session->alive()) {
continue;
}
BOOST_LOG(info) << "qemu: putting console "sv << console.console_id << " back to "sv << console.width << "x"sv << console.height;
console.session->set_ui_info(console.console_id, console.width, console.height);
}
}
std::shared_ptr<session_t> shared_session(const std::string &address) {
src/platform/linux/qemu/session.h +34 −0
@@ -583,6 +583,20 @@
* @return Console input, or nullptr when the console doesn't exist or QEMU is gone.
*/
virtual std::shared_ptr<console_input_t> open_input(std::uint32_t console_id) = 0;
/**
* @brief Tell the guest what size its display should be, with `Console.SetUIInfo`.
* @details The same call a local viewer makes when its window is resized: QEMU passes the size to
* the guest's display device, which offers it as the preferred mode. Whether the guest takes it is
* up to the guest: a desktop that follows its preferred mode resizes, one without a display agent
* keeps its own size and Sunshine scales as before.
*
* @param console_id Id of the console to resize.
* @param width Requested width in pixels.
* @param height Requested height in pixels.
* @return True when QEMU accepted the call.
*/
virtual bool set_ui_info(std::uint32_t console_id, std::uint32_t width, std::uint32_t height) = 0;
};
/**
@@ -594,6 +608,26 @@
* @return Live session, or nullptr when QEMU is unreachable.
*/
std::shared_ptr<session_t> shared_session(const std::string &address);
/**
* @brief Ask a console for a size and remember the one it had, so it can be put back.
* @details The session is held until the size is restored: the display that asked for the change is
* usually gone by the time streaming stops, and a console whose size nobody remembers stays resized.
*
* @param session Session the console belongs to.
* @param console_id Console to resize.
* @param width Requested width in pixels.
* @param height Requested height in pixels.
* @param original_width Width the console had.
* @param original_height Height the console had.
* @return True when QEMU accepted the call.
*/
bool resize_console(const std::shared_ptr<session_t> &session, std::uint32_t console_id, std::uint32_t width, std::uint32_t height, std::uint32_t original_width, std::uint32_t original_height);
/**
* @brief Put every console resized by `resize_console()` back to the size it had.
*/
void restore_guest_size();
/**
* @brief Find a console by id or label.
src_assets/common/assets/web/config.html +1 −0
@@ -305,6 +305,7 @@
"av1_mode": 0,
"capture": "",
"qemu_dbus_address": "",
"qemu_resize_guest": "enabled",
"encoder": "",
},
},
src_assets/common/assets/web/configs/tabs/Advanced.vue +10 −0
@@ -1,6 +1,7 @@
<script setup>
import { ref } from 'vue'
import PlatformLayout from '../../PlatformLayout.vue'
import Checkbox from '../../Checkbox.vue'
const props = defineProps([
'platform',
@@ -94,6 +95,15 @@
v-model="config.qemu_dbus_address" />
<div class="form-text">{{ $t('config.qemu_dbus_address_desc') }}</div>
</div>
<!-- Resize the VM to the client -->
<Checkbox class="mb-3"
v-if="platform === 'linux' && config.capture === 'qemu'"
id="qemu_resize_guest"
locale-prefix="config"
v-model="config.qemu_resize_guest"
default="true"
></Checkbox>
<!-- Encoder -->
<div class="mb-3">
src_assets/common/assets/web/public/assets/locale/en.json +2 −0
@@ -377,6 +377,8 @@
"port_warning": "Exposing the Web UI to the internet is a security risk! Proceed at your own risk!",
"port_web_ui": "Web UI",
"qemu_dbus_address": "QEMU D-Bus Address",
"qemu_resize_guest": "Resize the VM to the client",
"qemu_resize_guest_desc": "Ask the VM to render at the size the client streams at, instead of scaling its picture. The guest applies the size only if its desktop follows the preferred mode, and the VM's own size is restored when streaming stops.",
"qemu_dbus_address_desc": "Where the VM's display is: the D-Bus address given to QEMU with -display dbus,addr=, qmp:<socket> for a QEMU with -display dbus,p2p=on, or libvirt:<domain> for a libvirt domain with a D-Bus display. Leave blank to use the session bus.",
"qp": "Quantization Parameter",
"qp_desc": "Some devices may not support Constant Bit Rate. For those devices, QP is used instead. Higher value means more compression, but less quality.",
tests/e2e/qemu/desktop/build_desktop.sh +14 −0
@@ -4,6 +4,7 @@
# Ubuntu 24.04 cloud image + XFCE autologin + PipeWire + test tools, configured by cloud-init on first boot.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
D="${DESKTOP_CACHE:-${HOME}/.cache/sunshine-qemu/desktop}"
Q="${QEMU:-qemu-system-x86_64}"
mkdir -p "$D"
@@ -54,6 +55,19 @@
- libvulkan1
- mesa-vulkan-drivers
write_files:
# applies the size the host asks for, which a full desktop environment would do itself; base64 so the
# unquoted heredoc around this file leaves the script's own expansions alone
- path: /usr/local/bin/sq-follow-resolution
permissions: '0755'
encoding: b64
content: $(base64 -w0 "${script_dir}/sq-follow-resolution")
- path: /etc/xdg/autostart/sq-follow-resolution.desktop
content: |
[Desktop Entry]
Type=Application
Name=Follow the host's display size
Exec=/usr/local/bin/sq-follow-resolution
NoDisplay=true
- path: /etc/lightdm/lightdm.conf.d/50-autologin.conf
content: |
[Seat:*]
tests/e2e/qemu/desktop/follow_resolution.sh +187 −0
@@ -1,0 +1,187 @@
#!/usr/bin/env bash
# Check that the VM renders at the size the client streams at, and gets its own size back afterwards.
#
# The guest boots at one size, a client streams at another, and this asserts three things: the guest's
# display really changed (xrandr inside it and QEMU's own framebuffer agree), the client decoded frames
# at that size with nothing scaled, and the guest is back to its own size once streaming stops.
#
# The guest needs something that applies its preferred mode; build_desktop.sh installs
# sq-follow-resolution for that, as GNOME and KDE do it themselves.
#
# Usage: follow_resolution.sh
#
# Environment:
# SUNSHINE_BIN Sunshine binary (default: <repo>/cmake-build-debug/sunshine)
# QEMU QEMU binary (default: qemu-system-x86_64)
# DESKTOP_CACHE desktop guest image (default: ~/.cache/sunshine-qemu/desktop)
# GUEST_WIDTH/GUEST_HEIGHT size the guest boots at (default: 1920x1080)
# CLIENT_WIDTH/CLIENT_HEIGHT size the client asks for (default: 1600x900)
# PORT Sunshine base port (default: 48489)
# RESIZE_ARTIFACTS logs and frames (default: <repo>/ci-artifacts/follow-resolution)
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "${script_dir}/../../../.." && pwd)"
sunshine_bin="${SUNSHINE_BIN:-${repo}/cmake-build-debug/sunshine}"
client_bin="${repo}/cmake-build-e2e-client/moonlight_e2e_client"
desktop_cache="${DESKTOP_CACHE:-${HOME}/.cache/sunshine-qemu/desktop}"
guest_width="${GUEST_WIDTH:-1920}"
guest_height="${GUEST_HEIGHT:-1080}"
client_width="${CLIENT_WIDTH:-1600}"
client_height="${CLIENT_HEIGHT:-900}"
port="${PORT:-48489}"
artifacts="${RESIZE_ARTIFACTS:-${repo}/ci-artifacts/follow-resolution}"
[[ -x "${sunshine_bin}" ]] || { echo "resize: missing ${sunshine_bin}" >&2; exit 2; }
[[ -x "${client_bin}" ]] || { echo "resize: missing ${client_bin}" >&2; exit 2; }
if [[ ! -f "${desktop_cache}/desktop-base.qcow2" ]]; then
echo "resize: SKIPPED: no desktop guest image; run ${script_dir}/build_desktop.sh" >&2
exit 77
fi
rm -rf "${artifacts}"
mkdir -p "${artifacts}"
work="$(mktemp -d "${TMPDIR:-/tmp}/sunshine-resize.XXXXXX")"
sunshine_pid=""
failure=""
cleanup() {
set +e
[[ -n "${sunshine_pid}" ]] && kill "${sunshine_pid}" 2> /dev/null
for pidfile in "${work}/vm/qemu.pid" "${work}/vm/dbus.pid"; do
[[ -f "${pidfile}" ]] && kill "$(cat "${pidfile}")" 2> /dev/null
done
cp "${work}/sunshine.log" "${artifacts}/" 2> /dev/null
rm -rf "${work}"
}
trap cleanup EXIT INT TERM
fail() {
echo "resize: FAIL: $*" >&2
failure="$*"
}
echo "resize: booting the guest at ${guest_width}x${guest_height}"
bus_address="$(SNAPSHOT=on "${script_dir}/run_desktop.sh" "${work}/vm" "${guest_width}" "${guest_height}" 2> "${artifacts}/boot.log")"
ssh_port="$(cat "${work}/vm/ssh.port")"
guest() {
ssh -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=20 \
-i "${desktop_cache}/ssh_key" -p "${ssh_port}" sq@127.0.0.1 "$@"
}
# what the guest thinks its screen is, as "<width>x<height>"
guest_size() {
guest 'DISPLAY=:0 xrandr 2>/dev/null | sed -n "s/^Screen 0:.*current \([0-9]*\) x \([0-9]*\),.*/\1x\2/p"' 2> /dev/null
}
for _ in $(seq 1 60); do
[[ -n "$(guest_size)" ]] && break
sleep 2
done
# an image built before the agent existed doesn't have it; a rebuilt one starts it from XDG autostart
if ! guest 'pgrep -f '[s]q-follow-resolution' > /dev/null' 2> /dev/null; then
echo "resize: the guest has no display agent running; installing it for this run"
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
-i "${desktop_cache}/ssh_key" -P "${ssh_port}" "${script_dir}/sq-follow-resolution" sq@127.0.0.1:/tmp/
# systemd-run returns as soon as the unit starts; backgrounding over SSH holds the channel open instead
# shellcheck disable=SC2016 # $(id -u) is for the guest's shell, not this one
guest 'sudo install -m755 /tmp/sq-follow-resolution /usr/local/bin/ && XDG_RUNTIME_DIR=/run/user/$(id -u) systemd-run --user --unit=sq-follow-resolution --setenv=DISPLAY=:0 /usr/local/bin/sq-follow-resolution' > /dev/null 2>&1
sleep 3
guest 'pgrep -f '[s]q-follow-resolution' > /dev/null' 2> /dev/null || fail "the display agent didn't start in the guest"
fi
before="$(guest_size)"
echo "resize: the guest starts at ${before}"
[[ "${before}" == "${guest_width}x${guest_height}" ]] || fail "the guest booted at ${before}, not ${guest_width}x${guest_height}"
cat > "${work}/apps.json" <<'EOF'
{"env": {}, "apps": [{"name": "Desktop", "image-path": "desktop.png"}]}
EOF
cat > "${work}/sunshine.conf" <<EOF
capture = qemu
qemu_dbus_address = ${bus_address}
encoder = software
port = ${port}
sunshine_name = sunshine-resize-${port}
file_state = ${work}/sunshine_state.json
credentials_file = ${work}/sunshine_credentials.json
pkey = ${work}/sunshine.key
cert = ${work}/sunshine.crt
file_apps = ${work}/apps.json
log_path = ${work}/sunshine.log
min_log_level = info
upnp = disabled
system_tray = disabled
origin_web_ui_allowed = pc
notify_pre_releases = disabled
EOF
api_user="e2e"
api_pass="$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
"${sunshine_bin}" "${work}/sunshine.conf" --creds "${api_user}" "${api_pass}" > "${work}/creds.log" 2>&1
"${sunshine_bin}" "${work}/sunshine.conf" > "${work}/sunshine.stdout" 2>&1 &
sunshine_pid=$!
for _ in $(seq 1 300); do
kill -0 "${sunshine_pid}" 2> /dev/null || { echo "resize: Sunshine exited early" >&2; tail -20 "${work}/sunshine.stdout" >&2; exit 1; }
curl -s --max-time 1 "http://127.0.0.1:${port}/serverinfo" | grep -q 'status_code="200"' && break
sleep 0.1
done
echo "resize: streaming at ${client_width}x${client_height}"
"${client_bin}" --port "${port}" --api-user "${api_user}" --api-pass "${api_pass}" --state-dir "${work}/client" \
--width "${client_width}" --height "${client_height}" --fps 30 --bitrate 20000 --frames 600 --timeout 120 \
--out "${artifacts}/frame.ppm" --summary "${artifacts}/summary.json" > "${artifacts}/client.log" 2>&1 &
client_pid=$!
during=""
for _ in $(seq 1 30); do
during="$(guest_size)"
[[ "${during}" == "${client_width}x${client_height}" ]] && break
sleep 2
done
echo "resize: during the stream the guest is at ${during}"
[[ "${during}" == "${client_width}x${client_height}" ]] || fail "the guest stayed at ${during} while the client streamed ${client_width}x${client_height}"
# QEMU's own framebuffer, so this doesn't rest on what the guest reports
if [[ -S "${work}/vm/qmp.sock" ]]; then
python3 - "${work}/vm/qmp.sock" "${artifacts}/framebuffer.ppm" <<'PY' > /dev/null 2>&1 || true
import json, socket, sys
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
f = s.makefile('rw')
f.readline()
def cmd(c):
f.write(json.dumps(c) + "\n")
f.flush()
while True:
line = json.loads(f.readline())
if 'return' in line or 'error' in line:
return line
cmd({"execute": "qmp_capabilities"})
cmd({"execute": "screendump", "arguments": {"filename": sys.argv[2]}})
PY
if [[ -f "${artifacts}/framebuffer.ppm" ]]; then
fb="$(head -2 "${artifacts}/framebuffer.ppm" | tr '\n' ' ' | awk '{print $2 "x" $3}')"
echo "resize: QEMU's framebuffer is ${fb}"
[[ "${fb}" == "${client_width}x${client_height}" ]] || fail "QEMU renders ${fb}, not ${client_width}x${client_height}"
fi
fi
wait "${client_pid}" || fail "the client failed; see ${artifacts}/client.log"
frame="$(head -2 "${artifacts}/frame.ppm" 2> /dev/null | tr '\n' ' ' | awk '{print $2 "x" $3}')"
echo "resize: the client decoded ${frame}"
[[ "${frame}" == "${client_width}x${client_height}" ]] || fail "the client decoded ${frame}"
after=""
for _ in $(seq 1 30); do
after="$(guest_size)"
[[ "${after}" == "${before}" ]] && break
sleep 2
done
echo "resize: after the stream the guest is at ${after}"
[[ "${after}" == "${before}" ]] || fail "the guest was left at ${after} instead of its own ${before}"
if [[ -n "${failure}" ]]; then
exit 1
fi
echo "resize: PASS: the guest followed the client to ${client_width}x${client_height} and went back to ${before}"
tests/e2e/qemu/desktop/serve_desktop.sh +22 −2
@@ -115,9 +115,29 @@
fi
fi
qemu_pid="$(cat "${work}/vm/qemu.pid" 2>/dev/null || true)"
# Watch both: Sunshine outlives a dead VM happily, and then serves a black screen with
# "no QEMU owns [org.qemu]" in its log. Exiting instead lets a service manager restart the pair.
watch_both() {
local deadline="${1:-}"
while :; do
if [[ -n "${qemu_pid}" ]] && ! kill -0 "${qemu_pid}" 2> /dev/null; then
echo "the VM exited; see ${work}/vm/qemu.log" >&2
return 1
fi
if ! kill -0 "${sunshine_pid}" 2> /dev/null; then
echo "Sunshine exited; see ${serve_dir}/sunshine.log" >&2
return 1
fi
[[ -n "${deadline}" ]] && (($(date +%s) >= deadline)) && return 0
sleep 2
done
}
if [[ -n "${SERVE_EXIT_AFTER:-}" ]]; then
sleep "${SERVE_EXIT_AFTER}"
watch_both "$(($(date +%s) + SERVE_EXIT_AFTER))"
else
echo "Ctrl+C to stop"
watch_both
wait "${sunshine_pid}"
fi
tests/e2e/qemu/desktop/sq-follow-resolution +28 −0
@@ -1,0 +1,28 @@
#!/usr/bin/env bash
# Apply the display size the host asks for, as GNOME and KDE do by themselves.
#
# Sunshine calls QEMU's Console.SetUIInfo with the size the client streams at, and the guest's
# virtio-gpu driver offers it as the connector's preferred mode. A desktop with no display agent
# never applies it, so this watches for the preferred mode changing and switches to it. XFCE has
# nothing of its own; a full desktop would use its own display manager instead.
set -u
export DISPLAY="${DISPLAY:-:0}"
output=""
while :; do
if [[ -z "${output}" ]]; then
output="$(xrandr 2> /dev/null | awk '/ connected/ {print $1; exit}')"
fi
if [[ -n "${output}" ]]; then
# the preferred mode is the one xrandr marks with "+"
preferred="$(xrandr 2> /dev/null | awk -v o="${output}" '
$1 == o {found = 1; next}
found && /^ / {if ($0 ~ /\+/) {print $1; exit}}
found && /connected/ {exit}')"
current="$(xrandr 2> /dev/null | awk -v o="${output}" '$1 == o {for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+x[0-9]+\+[0-9-]+\+[0-9-]+$/) {split($i, a, "+"); print a[1]; exit}}')"
if [[ -n "${preferred}" && -n "${current}" && "${preferred}" != "${current}" ]]; then
logger -t sq-follow-resolution "applying ${preferred} (was ${current})"
xrandr --output "${output}" --mode "${preferred}" 2>&1 | logger -t sq-follow-resolution
fi
fi
sleep 1
done
tests/unit/platform/linux/qemu/fake_qemu.h +58 −0
@@ -182,6 +182,7 @@
const gchar *interfaces[] = {"org.qemu.Display1.Keyboard", "org.qemu.Display1.Mouse", "org.qemu.Display1.MultiTouch", nullptr};
qemu_dbus_display1_console_set_interfaces(state.skeleton, interfaces);
g_signal_connect(state.skeleton, "handle-register-listener", G_CALLBACK(&fake_qemu_t::on_register_listener), &state);
g_signal_connect(state.skeleton, "handle-set-uiinfo", G_CALLBACK(&fake_qemu_t::on_set_ui_info), &state);
// input interfaces on the same object, like QEMU's dbus_display_console_new()
state.keyboard = qemu_dbus_display1_keyboard_skeleton_new();
@@ -823,6 +824,29 @@
}
/**
* @brief Sizes a console was asked for with `Console.SetUIInfo`.
*
* @param console_id Console to check.
* @return Requested sizes, oldest first.
*/
std::vector<std::pair<std::uint32_t, std::uint32_t>> ui_info_calls(std::uint32_t console_id) {
std::lock_guard lock {mutex};
return console_states[console_id].ui_info_calls;
}
/**
* @brief Decide whether the fake guest applies the size it is asked for.
* @details A desktop with a display agent does; one without keeps its own size, and Sunshine scales.
*
* @param console_id Console to set.
* @param follows Whether the size is applied.
*/
void set_guest_follows_ui_info(std::uint32_t console_id, bool follows) {
std::lock_guard lock {mutex};
console_states[console_id].guest_follows_ui_info = follows;
}
/**
* @brief Wait until a console received a number of input calls.
*
* @param console_id Console to check.
@@ -1240,6 +1264,8 @@
QemuDBusDisplay1Mouse *mouse {nullptr}; ///< Exported Mouse interface.
QemuDBusDisplay1MultiTouch *multitouch {nullptr}; ///< Exported MultiTouch interface.
std::vector<std::string> input_calls; ///< Input calls received, see `input_calls()`.
std::vector<std::pair<std::uint32_t, std::uint32_t>> ui_info_calls; ///< Sizes asked for with `SetUIInfo`.
bool guest_follows_ui_info {true}; ///< Whether the fake guest applies the size, as a desktop with a display agent does.
};
/**
@@ -1328,5 +1354,37 @@
}
record_input(state, call);
qemu_dbus_display1_mouse_complete_rel_motion(object, invocation);
return TRUE;
}
/**
* @brief Take a `Console.SetUIInfo` call, like QEMU passing the size to the guest.
*
* @param object Console skeleton.
* @param invocation Method invocation.
* @param width_mm Physical width, unused.
* @param height_mm Physical height, unused.
* @param xoff Horizontal offset, unused.
* @param yoff Vertical offset, unused.
* @param width Requested width in pixels.
* @param height Requested height in pixels.
* @param data Console state.
* @return TRUE, the call is handled.
*/
static gboolean on_set_ui_info(QemuDBusDisplay1Console *object, GDBusMethodInvocation *invocation, guint16 width_mm, guint16 height_mm, gint xoff, gint yoff, guint width, guint height, gpointer data) {
(void) width_mm;
(void) height_mm;
(void) xoff;
(void) yoff;
auto state = (console_state_t *) data;
{
std::scoped_lock lock {state->owner->mutex};
state->ui_info_calls.emplace_back(width, height);
}
if (state->guest_follows_ui_info) {
qemu_dbus_display1_console_set_width(object, width);
qemu_dbus_display1_console_set_height(object, height);
}
qemu_dbus_display1_console_complete_set_uiinfo(object, invocation);
return TRUE;
}
tests/unit/platform/linux/qemu/test_capture.cpp +74 −0
@@ -83,6 +83,7 @@
saved_capture = config::video.capture;
saved_address = config::video.qemu_dbus_address;
saved_output_name = config::video.output_name;
saved_resize_guest = config::video.qemu_resize_guest;
bus = std::make_unique<qemu_test::private_bus_t>();
if (!bus->ok()) {
@@ -109,6 +110,7 @@
config::video.capture = saved_capture;
config::video.qemu_dbus_address = saved_address;
config::video.output_name = saved_output_name;
config::video.qemu_resize_guest = saved_resize_guest;
fake.reset();
bus.reset();
BaseTest::TearDown();
@@ -172,8 +174,80 @@
std::string saved_capture;
std::string saved_address;
std::string saved_output_name;
bool saved_resize_guest {true};
};
} // namespace
// @tag requirements: [REQ-CAP-007]
TEST_F(QemuCaptureTest, AsksTheVmToRenderAtTheSizeTheClientStreams) {
auto display = open_display("1", 1, 8, 4);
ASSERT_NE(display, nullptr);
// stream_config() asks for 1280x720; console 1 renders at 64x48
const auto asked = fake->ui_info_calls(1);
ASSERT_EQ(asked.size(), 1u);
EXPECT_EQ(asked.front().first, 1280u);
EXPECT_EQ(asked.front().second, 720u);
}
// @tag requirements: [REQ-CAP-007]
TEST_F(QemuCaptureTest, DoesntAskAgainOnceTheVmRendersAtThatSize) {
auto display = open_display("1", 1, 8, 4);
ASSERT_NE(display, nullptr);
ASSERT_EQ(fake->ui_info_calls(1).size(), 1u);
display.reset();
// the fake guest applied the size, so the second session has nothing to ask for
auto again = open_display("1", 1, 8, 4);
ASSERT_NE(again, nullptr);
EXPECT_EQ(fake->ui_info_calls(1).size(), 1u);
}
// @tag requirements: [REQ-CAP-007]
TEST_F(QemuCaptureTest, StreamsTheVmsOwnSizeWhenItIgnoresTheRequest) {
fake->set_guest_follows_ui_info(1, false);
auto display = open_display("1", 1, 8, 4);
ASSERT_NE(display, nullptr);
EXPECT_EQ(fake->ui_info_calls(1).size(), 1u);
// a guest without a display agent keeps its size, and the stream carries it as before
EXPECT_EQ(display->width, 8);
EXPECT_EQ(display->height, 4);
std::vector<std::uint8_t> captured;
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&img, bool frame_captured) {
if (!frame_captured) {
return true;
}
captured.assign(img->data, img->data + img->height * img->row_pitch);
return false;
});
EXPECT_EQ(status, platf::capture_e::ok);
EXPECT_EQ(captured.size(), 8u * 4 * 4);
}
// @tag requirements: [REQ-CAP-007]
TEST_F(QemuCaptureTest, LeavesTheVmsSizeAloneWhenResizingIsOff) {
config::video.qemu_resize_guest = false;
auto display = open_display("1", 1, 8, 4);
ASSERT_NE(display, nullptr);
EXPECT_TRUE(fake->ui_info_calls(1).empty());
}
// @tag requirements: [REQ-CAP-007]
TEST_F(QemuCaptureTest, PutsTheVmsSizeBackWhenStreamingStops) {
auto display = open_display("1", 1, 8, 4);
ASSERT_NE(display, nullptr);
ASSERT_EQ(fake->ui_info_calls(1).size(), 1u);
display.reset();
platf::streaming_will_stop();
// console 1 was 64x48 before the client asked for 1280x720
const auto asked = fake->ui_info_calls(1);
ASSERT_EQ(asked.size(), 2u);
EXPECT_EQ(asked.back().first, 64u);
EXPECT_EQ(asked.back().second, 48u);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, VerifyAndListGraphicConsoles) {