Skip to content

Commit

Permalink
qemu_test: Add NixOS test UI module
Browse files Browse the repository at this point in the history
Right now this is a module that encodes every frame delta from QEMU to
an intermediate format so that we can use FFmpeg to encode it into a
proper video.

In the end, the goal is to have videos of the graphical machine output
for the NixOS tests, which is especially useful in tests involving X
where we're basically blind when we run into a race condition or a
loaded Hydra node causes the test to fail.

The reason why I picked the approach to use an intermediate format and
encode it properly later is based on my benchmarks of some seemingly
simple approaches I tried before.

First of all I tried to search the web for solutions that already
existed and found a few, but they weren't really suitable:

  * Use the screendump QMP command to collect frames from the VM, which
    works to some degree but it misses frames.
  * Enable SPICE[1] and capture video from the server, which I actually
    tried to implement before the next option. However, existing
    solutions for capturing video off a SPICE server are rare and when
    testing with my own PoC implementation, I got frame drops as well
    and I didn't manage to capture early boot.
  * Try a patch[2] from the QEMU development mailing list, which adds a
    HMP command to capture and encode it directly to a video. This was
    the slowest option of all and it even lead to test failures because
    we got a timeout during VM startup.
  * Similar results to SPICE I had when capturing video using VNC and
    VncProxy[3].

So I dug through the QEMU code base and found out that UI modules get
frame deltas from Pixman, which is perfect for us, because we're not
losing frames and it also allows direct access to pixel data. It also is
fast and I couldn't even properly benchmark the overhead properly as
tests usually tend to vary in speed for a few seconds.

Before actually writing our own intermediate format, I tried to use an
existing format that would be suitable for us. The requirements for this
format would be to support different frame sizes and variable
framerates, plus it needs to be very fast to encode.

While asking in the #ffmpeg channel on freenode, the best format for
these requirements would be using the NUT[4] format (thanks to "furq"
for the suggestion). However while reading the format specification I
came to the realisation that our requirements are so simple that even
NUT is complicated in comparison, which is why I written our own format.

The specification is as follows:

  The first byte (the opcode) is either an 'S' (0x53, for "switch") or
  an 'U' (0x55, for "update") and determines the format of the following
  data.

  A "switch" is a surface change, like eg. a resize of the display and
  the data following the opcode are the dimensions (width and height,
  both are unsigned 32 bit integers), format (unsigned 8 bit integer)
  and bytes per pixel (unsigned 8 bit integer, currently either 2 or 4)
  of the surface.

  An "update" is a portion of the region that has changed since the last
  update and it's followed by X, Y, width and height (all 32 bit
  unsigned integers) coordinates of the updated region, the absolute
  time (64 bit unsigned integer) and the raw frame data afterwards. Note
  that we don't provide a length here, because we can infer that from
  the bytes per pixel of the last "switch" packet and the coordinates.

  All of the data is in the native endian format of the host processor
  architecture, which is not a problem, because encoding of the final
  video will take place on the same processor architecture.

All of the data is also gzip compressed, so that we don't accumulate
gigabytes of frame data during test runs.

I also moved the qemu_test expression out of the default.nix of the main
qemu expression, so that when we improve this we don't accidentally
break stuff for users of the normal QEMU.

[1]: https://www.spice-space.org/
[2]: https://lists.gnu.org/archive/html/qemu-devel/2017-05/msg00865.html
[3]: https://github.com/amitbet/vncproxy
[4]: https://ffmpeg.org/~michael/nut.txt

Signed-off-by: aszlig <aszlig@nix.build>
  • Loading branch information
aszlig committed Aug 29, 2020
1 parent 64ad3ef commit db8e70f
Show file tree
Hide file tree
Showing 3 changed files with 351 additions and 2 deletions.
10 changes: 8 additions & 2 deletions pkgs/applications/virtualization/qemu/default.nix
Expand Up @@ -79,8 +79,10 @@ stdenv.mkDerivation rec {
./no-etc-install.patch
./fix-qemu-ga.patch
./9p-ignore-noatime.patch
] ++ optional nixosTestRunner ./force-uid0-on-9p.patch
++ optionals stdenv.hostPlatform.isMusl [
] ++ optionals nixosTestRunner [
./force-uid0-on-9p.patch
./nixos-test-ui.patch
] ++ optionals stdenv.hostPlatform.isMusl [
(fetchpatch {
url = "https://raw.githubusercontent.com/alpinelinux/aports/2bb133986e8fa90e2e76d53369f03861a87a74ef/main/qemu/xattr_size_max.patch";
sha256 = "1xfdjs1jlvs99hpf670yianb8c3qz2ars8syzyz8f2c2cp5y4bxb";
Expand All @@ -96,6 +98,10 @@ stdenv.mkDerivation rec {
})
];

postPatch = optionalString nixosTestRunner ''
cat ${./nixos-test-ui.c} > ui/nixos-test.c
'';

hardeningDisable = [ "stackprotector" ];

preConfigure = ''
Expand Down
238 changes: 238 additions & 0 deletions pkgs/applications/virtualization/qemu/nixos-test-ui.c
@@ -0,0 +1,238 @@
#include "qemu/osdep.h"
#include "qemu-common.h"
#include "qemu/timer.h"
#include "ui/console.h"

#include <zlib.h>

static DisplayChangeListener *dcl;
static gzFile output_video;

/* These values are directly used in our intermediate format so that they can
* later be mapped back in avutil. The constants here are just so that we have
* the same naming conventions as avutil.
*
* Note also, that this is used in an uint8_t so be careful to not overflow and
* also make sure that in case it is a packed pixel format it needs to be big
* endian. The necessary conversion for little endian systems is done when we
* encode it into a more common video format.
*/
#define AV_PIX_FMT_RGB555BE 1
#define AV_PIX_FMT_RGB565BE 2
#define AV_PIX_FMT_0RGB 3
#define AV_PIX_FMT_RGB0 4
#define AV_PIX_FMT_BGR0 5

static bool write_packet(void *buf, size_t len)
{
int i, written;

for (i = 0; i < len; i += written) {
written = gzwrite(output_video, buf + i, len - i);
if (written <= 0) {
fputs("Error writing compressed video packet.\n", stderr);
return false;
}
}

return true;
}

static void nixos_test_update(DisplayChangeListener *dcl,
int x, int y, int w, int h)
{
DisplaySurface *surf = qemu_console_surface(dcl->con);
uint32_t x32 = x, y32 = y, w32 = w, h32 = h;
uint64_t timestamp;
size_t offset, datalen;
void *buf, *bufp, *sdata;

if (surf == NULL)
return;

timestamp = qemu_clock_get_ns(QEMU_CLOCK_REALTIME);
offset = surface_bytes_per_pixel(surf) * x + surface_stride(surf) * y;
datalen = surface_bytes_per_pixel(surf) * w * h;

/* Bitstring: <<Op:8, X:32, Y:32, W:32, H:32, Time:64, Data/binary>>
* Length: 1 + 4 + 4 + 4 + 4 + 8 + DataLen = 25 + DataLen
*/
buf = g_malloc(25 + datalen);
*(char*)buf = 'U';
memcpy(buf + 1, &x32, 4);
memcpy(buf + 5, &y32, 4);
memcpy(buf + 9, &w32, 4);
memcpy(buf + 13, &h32, 4);
memcpy(buf + 17, &timestamp, 8);

bufp = buf + 25;
sdata = surface_data(surf) + offset;

/* Extract only the data of the rectangle but also taking stride into
* account, so we don't need to handle padding while encoding this
* intermediate format into a common video format.
*/
while (h-- > 0) {
memcpy(bufp, sdata, w * surface_bytes_per_pixel(surf));
sdata += surface_stride(surf);
bufp += w * surface_bytes_per_pixel(surf);
}

if (!write_packet(buf, 25 + datalen)) {
g_free(buf);
exit(1);
}
g_free(buf);
}

static void nixos_test_switch(DisplayChangeListener *dcl,
DisplaySurface *new_surface)
{
void *buf;
uint32_t width, height;
uint8_t format, bpp;

if (new_surface == NULL)
return;

width = surface_width(new_surface);
height = surface_height(new_surface);
bpp = surface_bytes_per_pixel(new_surface);

switch (new_surface->format) {
case PIXMAN_x1r5g5b5: format = AV_PIX_FMT_RGB555BE; break;
case PIXMAN_r5g6b5: format = AV_PIX_FMT_RGB565BE; break;
case PIXMAN_x8r8g8b8: format = AV_PIX_FMT_0RGB; break;
case PIXMAN_r8g8b8x8: format = AV_PIX_FMT_RGB0; break;
case PIXMAN_b8g8r8x8: format = AV_PIX_FMT_BGR0; break;
default: return;
}

/* Bitstring: <<Op:8, Width:32, Height:32, Format:8, BPP:8>>
* Length: 1 + 4 + 4 + 1 + 1 = 11
*/
buf = g_malloc(11);
*(char*)buf = 'S';
memcpy(buf + 1, &width, 4);
memcpy(buf + 5, &height, 4);
memcpy(buf + 9, &format, 1);
memcpy(buf + 10, &bpp, 1);

if (!write_packet(buf, 11)) {
g_free(buf);
exit(1);
}
g_free(buf);

/* We have a new surface (or a resize), so we need to send an update for
* the whole new surface size to make sure we don't get artifacts from the
* old surface. */
nixos_test_update(dcl, 0, 0, surface_width(new_surface),
surface_height(new_surface));
}

static void nixos_test_refresh(DisplayChangeListener *dcl)
{
graphic_hw_update(dcl->con);
}

static bool nixos_test_check_format(DisplayChangeListener *dcl,
pixman_format_code_t format)
{
switch (format) {
case PIXMAN_x1r5g5b5:
case PIXMAN_r5g6b5:
case PIXMAN_x8r8g8b8:
case PIXMAN_r8g8b8x8:
case PIXMAN_b8g8r8x8:
return true;
default:
return false;
}
}

static const DisplayChangeListenerOps dcl_ops = {
.dpy_name = "nixos-test",
.dpy_gfx_update = nixos_test_update,
.dpy_gfx_switch = nixos_test_switch,
.dpy_gfx_check_format = nixos_test_check_format,
.dpy_refresh = nixos_test_refresh,
};

static void nixos_test_cleanup(void)
{
gzclose_w(output_video);
}

#define HEADER \
"# This file contains raw frame data in an internal format used by\n" \
"# the 'nixos-test' QEMU UI module optimized for low overhead.\n" \
"#\n" \
"# In order to get this into a format that's actually watchable,\n" \
"# please use the 'nixos-test-encode-video' binary from the\n" \
"# 'qemu_test' package to encode it into another video format.\n" \
"#\n"

static void nixos_test_display_init(DisplayState *ds, DisplayOptions *o)
{
int outfd;
QemuConsole *con;

outfd = qemu_open(o->capture_file,
O_WRONLY | O_CREAT | O_APPEND | O_BINARY, 0666);

if (outfd < 0) {
fprintf(stderr, "Failed to open file '%s' for video capture: %s\n",
o->capture_file, strerror(errno));
exit(1);
}

/* When at the beginning of the file, let's write a short description about
* the file in question so that people stumbling over it know what to do
* with it.
*/
if (lseek(outfd, 0, SEEK_END) == 0) {
if (qemu_write_full(outfd, HEADER, sizeof(HEADER) -1) != sizeof HEADER)
fprintf(stderr, "Unable to write video file header to '%s'.\n",
o->capture_file);
}

/* We're using gzip here because we have a lot of repetition in frame data
* and a test run without compressing the intermediate format can easily
* grow to a few gigabytes, which will also cause slowdowns on slow disks.
*/
if ((output_video = gzdopen(outfd, "ab1")) == NULL) {
fprintf(stderr, "Unable to associate gzip stream with '%s'.\n",
o->capture_file);
qemu_close(outfd);
exit(1);
}

con = qemu_console_lookup_by_index(0);
if (!con) {
fputs("Unable to look up console 0.\n", stderr);
exit(1);
}

dcl = g_new0(DisplayChangeListener, 1);
dcl->ops = &dcl_ops;
dcl->con = con;
register_displaychangelistener(dcl);

fprintf(stderr, "Capturing intermediate video stream to '%s'.\n",
o->capture_file);

atexit(nixos_test_cleanup);
}

static QemuDisplay qemu_display_nixos_test = {
.type = DISPLAY_TYPE_NIXOS_TEST,
.init = nixos_test_display_init,
};

static void register_nixos_test(void)
{
qemu_display_register(&qemu_display_nixos_test);
}

type_init(register_nixos_test);
105 changes: 105 additions & 0 deletions pkgs/applications/virtualization/qemu/nixos-test-ui.patch
@@ -0,0 +1,105 @@
diff --git a/qapi/ui.json b/qapi/ui.json
index 9d6721037f..718db40fc7 100644
--- a/qapi/ui.json
+++ b/qapi/ui.json
@@ -1136,7 +1136,7 @@
#
##
{ 'enum' : 'DisplayType',
- 'data' : [ 'default', 'none', 'gtk', 'sdl',
+ 'data' : [ 'default', 'none', 'gtk', 'sdl', 'nixos-test',
'egl-headless', 'curses', 'cocoa',
'spice-app'] }

@@ -1158,6 +1158,7 @@
{ 'union' : 'DisplayOptions',
'base' : { 'type' : 'DisplayType',
'*full-screen' : 'bool',
+ '*capture-file' : 'str',
'*window-close' : 'bool',
'*show-cursor' : 'bool',
'*gl' : 'DisplayGLMode' },
diff --git a/qemu-options.hx b/qemu-options.hx
index 708583b4ce..1599e36902 100644
--- a/qemu-options.hx
+++ b/qemu-options.hx
@@ -1735,6 +1735,7 @@ DEF("display", HAS_ARG, QEMU_OPTION_display,
#if defined(CONFIG_OPENGL)
"-display egl-headless[,rendernode=<file>]\n"
#endif
+ "-display nixos-test=<filename>\n"
"-display none\n"
" select display backend type\n"
" The default display is equivalent to\n "
@@ -1796,6 +1797,10 @@ SRST
Start QEMU as a Spice server and launch the default Spice client
application. The Spice server will redirect the serial consoles
and QEMU monitors. (Since 4.0)
+
+ ``nixos-test``
+ Write raw video frames into the given filename instead of displaying
+ anything.
ERST

DEF("nographic", 0, QEMU_OPTION_nographic,
@@ -2223,6 +2228,15 @@ SRST
valid audiodev.
ERST

+DEF("nixos-test", HAS_ARG, QEMU_OPTION_nixos_test,
+ "-nixos-test <filename> shorthand for -display nixos-test=<filename>\n",
+ QEMU_ARCH_ALL)
+SRST
+``-nixos-test filename``
+ Instead of displaying anything, capture all the output as raw video frames
+ into the file name given by ``filename``.
+ERST
+
ARCHHEADING(, QEMU_ARCH_I386)

ARCHHEADING(i386 target only:, QEMU_ARCH_I386)
diff --git a/softmmu/vl.c b/softmmu/vl.c
index 4eb9d1f7fd..5a3596a0d9 100644
--- a/softmmu/vl.c
+++ b/softmmu/vl.c
@@ -1966,6 +1966,15 @@ static void parse_display(const char *p)
error_report("VNC requires a display argument vnc=<display>");
exit(1);
}
+ } else if (strstart(p, "nixos-test", &opts)) {
+ dpy.type = DISPLAY_TYPE_NIXOS_TEST;
+ if (*opts == '=') {
+ dpy.capture_file = opts + 1;
+ } else {
+ error_report("the nixos-test option requires a filename argument"
+ " nixos-test=<filename>");
+ exit(1);
+ }
} else {
parse_display_qapi(p);
}
@@ -3430,6 +3439,10 @@ void qemu_init(int argc, char **argv, char **envp)
error_report("SDL support is disabled");
exit(1);
#endif
+ case QEMU_OPTION_nixos_test:
+ dpy.type = DISPLAY_TYPE_NIXOS_TEST;
+ dpy.capture_file = optarg;
+ break;
case QEMU_OPTION_pidfile:
pid_file = optarg;
break;
diff --git a/ui/Makefile.objs b/ui/Makefile.objs
index 504b196479..d397253a11 100644
--- a/ui/Makefile.objs
+++ b/ui/Makefile.objs
@@ -48,6 +48,9 @@ x_keymap.o-cflags := $(X11_CFLAGS)
x_keymap.o-libs := $(X11_LIBS)
endif

+common-obj-y += nixos-test.mo
+nixos-test.mo-objs := nixos-test.o
+
common-obj-$(CONFIG_CURSES) += curses.mo
curses.mo-objs := curses.o
curses.mo-cflags := $(CURSES_CFLAGS) $(ICONV_CFLAGS)

0 comments on commit db8e70f

Please sign in to comment.