ref:3e0c14fd38cbe850737a10bc07961f967fa20158

test(e2e): make the guest reboot test deterministic and detect frozen streams

- The pattern guest now shows text mode (720x400) for a second before switching to mode 13h, so a reboot always changes the display size twice; before, whether QEMU sent the text-mode scanout depended on timing. - qmp.py waits for QEMU's RESET event after system_reset. - The headless client counts picture changes after the wait file, and the reset check requires them (the square blinks), a bounded frame gap, and E2E_RESET_MIN_DISPLAYS display re-creations (3; QEMU 8.2 never sends the text-mode scanout over D-Bus, verified with a Python listener, so it needs 1). - Document the GL (DMABUF) variant and the reboot test. Refs #3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: 3e0c14fd38cbe850737a10bc07961f967fa20158
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-12 23:06
Parents: 4ee6280
6 files changed +130 -40
Type
JOURNAL.md +31 −18
@@ -127,28 +127,41 @@
(`cmake-build-release-cuda`), `E2E_ENCODER=nvenc E2E_WIDTH=1920 E2E_HEIGHT=1080 E2E_FPS=60
E2E_FRAMES=600`, KVM, guest framebuffer 640x400 scaled by Sunshine, ~330 samples per run.
| Build | Transport (QEMU) | Encoder | p50 (3 runs) | p95 (3 runs) |
|----------------------------|----------------------------|---------------|--------------------|--------------------|
| before (`98cb3d22`) | shared memory map (11.1.1) | NVENC | 13.5 / 11.1 / 13.6 | 21.0 / 18.6 / 21.8 |
| after (damage-driven) | shared memory map (11.1.1) | NVENC | 4.0 / 4.5 / 3.9 | 9.8 / 10.0 / 9.4 |
| after (damage-driven) | D-Bus messages (8.2.2) | NVENC | 3.9 | 8.7 |
| Build | Transport (QEMU) | Encoder | p50 (runs) | p95 (runs) |
|--------------------------------|----------------------------|---------------|--------------------------|--------------------------|
| before (`98cb3d22`) | shared memory map (11.1.1) | NVENC | 13.5 / 11.1 / 13.6 | 21.0 / 18.6 / 21.8 |
| before, rerun at the end | shared memory map (11.1.1) | NVENC | 10.9 / 13.7 | 17.8 / 20.5 |
| damage-driven (`225def94`) | shared memory map (11.1.1) | NVENC | 4.0 / 4.5 / 3.9 / 4.0 | 9.8 / 10.0 / 9.4 / 4.5 |
| damage-driven (`225def94`) | D-Bus messages (8.2.2) | NVENC | 3.9 | 8.7 |
| damage-driven (`225def94`) | shared memory map (11.1.1) | software x264 | 4.4 | 6.4 |
| final phase-1 capture | shared memory map (11.1.1) | NVENC | 3.9 / 3.9 / 3.9 / 3.8 / 4.0 / 3.9 | 4.4 / 5.4 / 5.1 / 4.6 / 4.4 / 4.7 |
| after (damage-driven) | shared memory map (11.1.1) | software x264 | 4.4 | 6.4 |
The shm + hardware encoder target from #3 (p95 ≤ 12 ms at 1080p60) is met: p95 went from ~21 ms to
~10 ms. The remaining p95 is NVENC plus the RAM→CUDA upload of the 1080p frame; x264 is faster on
this mostly static 640x400 guest picture scaled up. Unit tests pin the behavior:
`QemuCaptureTest.PushesDamageWithinMillisecondsNotAtTheNextTick` fails on the old loop (median
The shm + hardware encoder target from #3 (p95 ≤ 12 ms at 1080p60) is met: p95 went from 18-22 ms
to 4-10 ms, p50 from 11-14 ms to ~4 ms. The spread of the "after" p95 (4.4 vs ~10 ms for the same
binary) is host load: the first three runs overlapped with other agents' work on this machine;
rerunning that binary on a quiet host gave 4.5 ms. The remaining ~4 ms is the RAM→CUDA upload and
NVENC. Unit tests pin the behavior: `QemuCaptureTest.PushesDamageWithinMillisecondsNotAtTheNextTick`
fails on the old loop (median 26 ms at 30 fps) and passes now; `BurstsOfDamagePushAtMostOneFramePerInterval`
and `NoDamageRepeatsNoFrames` pass on both.
26 ms at 30 fps) and passes now, `BurstsOfDamagePushAtMostOneFramePerInterval` and
`NoDamageRepeatsNoFrames` pass on both.
### Mode changes, cursor and console selection (REQ-CAP-004/005/006)
- **Reboot during a stream** (`E2E_RESET_AFTER_FRAMES=30 E2E_FRAMES=60`, Debug build, software
encoding): PASS on QEMU 11.1.1 (map path; the firmware's 720x400 text mode and the guest's
640x400 mode each re-create the display, longest gap between decoded frames 143-167 ms) and on
QEMU 8.2.2 (message path; longest gap 64 ms). On 8.2 Sunshine logged no size change during the
reset, so no display re-creation happened; I assume its VGA BIOS text mode is also 640x400 but
didn't check it with a screendump.
- **Reboot during a stream** (`E2E_RESET_AFTER_FRAMES`): the script sends QMP `system_reset`,
waits for QEMU's `RESET` event, then requires more decoded frames, the pattern again, at least
10 picture changes after the reset (the square blinks, so a frozen stream fails), a bounded gap
between frames and at least `E2E_RESET_MIN_DISPLAYS` (3) displays created during the stream.
The first version of the guest switched to mode 13h immediately, so whether a reset produced a
720x400 text scanout was timing-dependent (seen in one run, not in the next ones). `pattern.S`
now stays in text mode for one second (`int 15h`, AH=86h), so every reboot changes the size
twice. Results with that guest:
- QEMU 11.1.1, Debug, software: PASS, 4 displays during the stream, 35 picture changes after the
reset, longest frame gap 172 ms.
- QEMU 11.1.1, Release, NVENC, 1080p60: PASS, 3 displays, longest gap 231 ms.
- QEMU 8.2.2, Debug, software, `E2E_RESET_MIN_DISPLAYS=1`: PASS, 31 picture changes, gap 61 ms.
QEMU 8.2's D-Bus display never sends the VGA text-mode scanout: a Python listener on the same
VM saw only `Scanout 640x480` at registration and `Scanout 640x400` after the reset, while a QMP
`screendump` taken during the reset was 720x400. So on 8.2 a reboot doesn't re-create the
display; that's QEMU, not the backend.
- Disable keeps the display and pushes a black frame; the next update at the same size brings the
picture back. Size changes still return `reinit` (unit tests replay Disable → firmware size → OS
size).
tests/e2e/moonlight_client/main.cpp +15 −1
@@ -84,6 +84,8 @@
SwsContext *sws {nullptr}; ///< Converter to RGB24.
std::vector<std::uint8_t> rgb; ///< Last decoded picture, RGB24.
std::vector<std::chrono::steady_clock::time_point> decode_times; ///< When each frame was decoded.
std::uint64_t picture_hash {0}; ///< Checksum of the last decoded picture.
int picture_changes {0}; ///< Decoded frames whose picture differs from the previous one.
int rgb_width {0}; ///< Width of `rgb`.
int rgb_height {0}; ///< Height of `rgb`.
int decoded {0}; ///< Decoded frame count.
@@ -152,6 +154,14 @@
state.rgb_height = h;
state.decoded += 1;
state.decode_times.push_back(std::chrono::steady_clock::now());
std::uint64_t hash = 1469598103934665603ULL;
for (std::size_t i = 0; i < state.rgb.size(); i += 7) {
hash = (hash ^ state.rgb[i]) * 1099511628211ULL;
}
if (hash != state.picture_hash) {
state.picture_hash = hash;
state.picture_changes += 1;
}
}
return DR_OK;
}
@@ -490,6 +500,7 @@
bool matched = false;
bool ready_written = false;
std::optional<int> frames_at_wait;
int changes_at_wait = 0;
std::optional<std::chrono::steady_clock::time_point> wait_start;
std::vector<std::array<int, 3>> last_colors;
const auto deadline = stream_start + std::chrono::seconds(opts.timeout_s);
@@ -506,6 +517,7 @@
continue;
}
frames_at_wait = state.decoded;
changes_at_wait = state.picture_changes;
wait_start = std::chrono::steady_clock::now();
std::fprintf(stderr, "e2e: %s appeared after %d frames\n", opts.wait_file.c_str(), state.decoded);
}
@@ -530,11 +542,13 @@
std::vector<double> latencies;
int decoded = 0;
int picture_changes = 0;
double max_gap_ms = 0;
{
std::lock_guard lock {state.mutex};
latencies = state.host_latency_ms;
decoded = state.decoded;
picture_changes = state.picture_changes - changes_at_wait;
// the longest time without a decoded frame, from the first frame (or the wait file) to the end
auto previous = wait_start;
for (const auto &t : state.decode_times) {
@@ -552,7 +566,7 @@
for (std::size_t i = 0; i < last_colors.size(); ++i) {
summary << (i ? "," : "") << "[" << last_colors[i][0] << "," << last_colors[i][1] << "," << last_colors[i][2] << "]";
}
summary << "],\"frames_after_wait\":" << (decoded - frames_at_wait.value_or(0)) << ",\"max_frame_gap_ms\":" << max_gap_ms << ",\"terminated\":" << (state.terminated ? "true" : "false") << "}";
summary << "],\"frames_after_wait\":" << (decoded - frames_at_wait.value_or(0)) << ",\"picture_changes_after_wait\":" << picture_changes << ",\"max_frame_gap_ms\":" << max_gap_ms << ",\"terminated\":" << (state.terminated ? "true" : "false") << "}";
std::printf("%s\n", summary.str().c_str());
if (!opts.summary.empty()) {
std::ofstream {opts.summary} << summary.str() << "\n";
tests/e2e/qemu/e2e_stream.sh +20 −7
@@ -22,8 +22,12 @@
# E2E_GL set to 1 for QEMU's GL display (virtio-vga-gl, gl=on): DMABUF scanouts (REQ-CAP-003).
# Needs a DRM render node; exits 77 (skipped) without /dev/dri/renderD*.
# E2E_RENDERNODE render node for E2E_GL=1, passed to QEMU (rendernode=) and Sunshine (adapter_name)
# E2E_RESET_AFTER_FRAMES reboot the guest with QMP system_reset after this many decoded frames, then
# E2E_RESET_AFTER_FRAMES reboot the guest with QMP system_reset after this many decoded frames (use more
# than one second of frames, so the pattern is up), then require the two mode changes
# of the reboot, E2E_FRAMES more frames and the pattern again (REQ-CAP-004)
# require E2E_FRAMES more frames and the pattern again (REQ-CAP-004)
# E2E_MAX_GAP_MS with E2E_RESET_AFTER_FRAMES, the longest allowed time without a decoded frame (default: 5000)
# E2E_RESET_MIN_DISPLAYS with E2E_RESET_AFTER_FRAMES, displays Sunshine must create during the stream (default: 3,
# the first one plus the reboot's text and graphics modes). QEMU 8.2 doesn't send the text mode
# scanout over D-Bus, so use 1 there.
# E2E_KEEP set to 1 to keep the work directory
set -euo pipefail
@@ -43,6 +47,7 @@
encoder="${E2E_ENCODER:-software}"
reset_after="${E2E_RESET_AFTER_FRAMES:-}"
max_gap_ms="${E2E_MAX_GAP_MS:-5000}"
min_displays="${E2E_RESET_MIN_DISPLAYS:-3}"
if [[ "${E2E_GL:-0}" == 1 ]]; then
if ! compgen -G "/dev/dri/renderD*" > /dev/null; then
@@ -157,7 +162,7 @@
done
if [[ -f "${work}/stream_ready" ]]; then
echo "e2e: rebooting the guest (system_reset)" >&2
"${script_dir}/qmp.py" "${work}/vm/qmp.sock" system_reset >&2 && touch "${work}/reset_done"
"${script_dir}/qmp.py" "${work}/vm/qmp.sock" system_reset --event RESET >&2 && touch "${work}/reset_done"
fi
) &
fi
@@ -181,17 +186,25 @@
set -e
if [[ ${result} == 0 && -n "${reset_after}" ]]; then
# the stream must have kept producing frames through the reboot; QEMU 11 sends the firmware's
# QEMU must have reset the VM, the guest's text mode (720x400) and pattern mode (640x400) must each
# have re-created the display, and frames must have kept coming the whole time
# 720x400 text mode (a display re-creation in Sunshine), QEMU 8.2 may coalesce it away
displays="$(sed -n '/CLIENT CONNECTED/,$p' "${work}/sunshine.log" | grep -c "qemu: streaming VM" || true)"
gap="$(sed -n 's/.*"max_frame_gap_ms":\([0-9.]*\).*/\1/p' "${artifacts}/summary.json")"
changes="$(sed -n 's/.*"picture_changes_after_wait":\([0-9]*\).*/\1/p' "${artifacts}/summary.json")"
if [[ ! -f "${work}/reset_done" ]]; then
echo "e2e: FAIL: the guest was never reset (no RESET event from QMP)" >&2
result=1
elif (( displays < min_displays )); then
echo "e2e: FAIL: expected at least ${min_displays} display(s) during the stream from the reboot's mode changes, got ${displays}" >&2
result=1
echo "e2e: FAIL: the guest was never reset" >&2
elif (( ${changes:-0} < 10 )); then
# the pattern's square blinks ~18 times a second: a frozen picture means QEMU stopped updating us
echo "e2e: FAIL: the picture changed only ${changes:-0} time(s) after the reset" >&2
result=1
elif ! awk -v gap="${gap:-999999}" -v max="${max_gap_ms}" 'BEGIN { exit !(gap <= max) }'; then
echo "e2e: FAIL: no frame for ${gap} ms around the reset (limit ${max_gap_ms} ms)" >&2
result=1
else
echo "e2e: reset survived: ${displays} display(s) during the stream, ${changes} picture changes after it, longest frame gap ${gap} ms" >&2
echo "e2e: reset survived: ${displays} display(s) during the stream, longest frame gap ${gap} ms" >&2
fi
fi
tests/e2e/qemu/guest/pattern.S +11 −0
@@ -6,6 +6,9 @@
* A 16x16 square in the center alternates between black and yellow on every BIOS timer tick
* (about 18 times a second), so QEMU keeps sending damage for it.
*
* The guest shows text mode (720x400) for a second before switching, so a reboot during a stream
* always changes the display size twice (REQ-CAP-004).
*
* Build: see build_guest.sh (GNU as + ld, no other tools).
*/
.code16
@@ -18,6 +21,14 @@
mov $0x7c00, %sp
cld
sti
/* text mode 03h (720x400) for one second first, so every boot and reboot changes the display mode */
mov $0x0003, %ax
int $0x10
mov $0x000f, %cx
mov $0x4240, %dx
mov $0x86, %ah
int $0x15
/* VGA mode 13h */
mov $0x0013, %ax
tests/e2e/qemu/qmp.py +32 −8
@@ -1,9 +1,11 @@
#!/usr/bin/env python3
"""Send one QMP command to a QEMU monitor socket and print the reply.
Usage: qmp.py <qmp-socket> <command> [json-arguments]
Usage: qmp.py <qmp-socket> <command> [json-arguments] [--event NAME]
With --event, also wait (up to 10 seconds) for the named QMP event after the reply, for example
Exits non-zero when the connection fails or QEMU returns an error.
RESET after system_reset. Exits non-zero when the connection fails, QEMU returns an error, or the
event doesn't arrive.
"""
import json
import socket
@@ -11,19 +13,26 @@
def main() -> int:
args = sys.argv[1:]
event = None
if len(sys.argv) < 3:
if "--event" in args:
index = args.index("--event")
event = args[index + 1]
del args[index:index + 2]
if len(args) < 2:
print(__doc__, file=sys.stderr)
return 2
path, command = sys.argv[1], sys.argv[2]
arguments = json.loads(sys.argv[3]) if len(sys.argv) > 3 else None
path, command = args[0], args[1]
arguments = json.loads(args[2]) if len(args) > 2 else None
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(10)
sock.connect(path)
stream = sock.makefile("rw")
events = []
def receive():
# skip asynchronous events until a reply arrives
# collect asynchronous events until a reply arrives
while True:
line = stream.readline()
if not line:
@@ -31,14 +40,29 @@
message = json.loads(line)
if "event" not in message:
return message
events.append(message["event"])
receive() # greeting
request = {"execute": command}
for request in ({"execute": "qmp_capabilities"}, {"execute": command, **({"arguments": arguments} if arguments else {})}):
stream.write(json.dumps(request) + "\n")
if arguments:
request["arguments"] = arguments
for message in ({"execute": "qmp_capabilities"}, request):
stream.write(json.dumps(message) + "\n")
stream.flush()
reply = receive()
if "error" in reply:
print(json.dumps(reply), file=sys.stderr)
return 1
print(json.dumps(reply))
if event:
while event not in events:
line = stream.readline()
if not line:
print(f"QMP connection closed before the {event} event", file=sys.stderr)
return 1
message = json.loads(line)
if "event" in message:
events.append(message["event"])
print(json.dumps({"event": event}))
return 0
tests/e2e/README.md +21 −6
@@ -8,7 +8,7 @@
| Path | What it is |
|------------------------------|-------------------------------------------------------------------------------------------|
| `qemu/guest/pattern.S` | 512-byte boot sector: VGA mode 13h with red, green, blue, white quadrants and a blinking square |
| `qemu/guest/pattern.S` | 512-byte boot sector: one second of text mode, then VGA mode 13h with red, green, blue, white quadrants and a blinking square |
| `qemu/guest/build_guest.sh` | Builds the guest disk image with GNU `as`/`ld` (no binaries are committed) |
| `qemu/run_vm.sh` | Starts a private `dbus-daemon` and QEMU with `-display dbus,addr=...` and `-audiodev dbus` |
| `qemu/e2e_stream.sh` | The end-to-end test (REQ-E2E-001), also used for latency numbers (REQ-NFR-001) and the guest reboot test (REQ-CAP-004) |
@@ -54,12 +54,27 @@
## Guest reboot during a stream
```bash
E2E_RESET_AFTER_FRAMES=60 E2E_FRAMES=60 tests/e2e/qemu/e2e_stream.sh
```
After 60 decoded frames the script reboots the guest with QMP `system_reset` and waits for QEMU's
`RESET` event. The guest shows text mode (720x400) for a second before drawing the pattern
(640x400), so the reboot changes the display size twice. The client then has to decode 60 more
frames and see the pattern again; the picture must change at least 10 times after the reset (the
square blinks), no gap between decoded frames may exceed `E2E_MAX_GAP_MS` (5000 ms), and Sunshine
must have created at least `E2E_RESET_MIN_DISPLAYS` (3) displays during the stream. QEMU 8.2 doesn't
send the text-mode scanout over D-Bus, so run it with `E2E_RESET_MIN_DISPLAYS=1`. The summary adds
`frames_after_wait`, `picture_changes_after_wait` and `max_frame_gap_ms`.
## GL display (DMABUF)
```bash
E2E_GL=1 E2E_ENCODER=vaapi E2E_RENDERNODE=/dev/dri/renderD128 tests/e2e/qemu/e2e_stream.sh
E2E_RESET_AFTER_FRAMES=30 E2E_FRAMES=60 tests/e2e/qemu/e2e_stream.sh
```
Runs QEMU with `-device virtio-vga-gl -display dbus,gl=on`, so Sunshine receives DMABUF scanouts
(REQ-CAP-003). It needs a DRM render node and exits 77 (skipped) without `/dev/dri/renderD*`, for
After 30 decoded frames the script reboots the guest with QMP `system_reset`. The client then has
to decode 60 more frames and see the pattern again, and no gap between decoded frames may exceed
`E2E_MAX_GAP_MS` (5000 ms by default). The summary adds `frames_after_wait` and
`max_frame_gap_ms`.
example under WSL2. `E2E_RENDERNODE` is passed to QEMU as `rendernode=` and to Sunshine as
`adapter_name`.
## Latency runs