vmspawn: add integration test for QMP client library

Test the QMP client library using a mock QMP server over a socketpair:

- test_qmp_client_basic: Verifies full handshake, query-status with
  response parsing, stop/cont commands, and asynchronous STOP event
  delivery via the sd-event I/O callback
- test_qmp_client_eof: Verifies that the client properly detects
  server disconnection (EOF) and returns a disconnect error

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
This commit is contained in:
Christian Brauner
2026-04-15 10:14:48 +02:00
parent e2af53db04
commit f8a7931d39
2 changed files with 519 additions and 0 deletions
+3
View File
@@ -431,6 +431,9 @@ executables += [
test_template + {
'sources' : files('test-progress-bar.c'),
},
test_template + {
'sources' : files('test-qmp-client.c'),
},
test_template + {
'sources' : files('test-qrcode-util.c'),
'dependencies' : libdl,
+516
View File
@@ -0,0 +1,516 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <fcntl.h>
#include <signal.h>
#include <sys/socket.h>
#include "sd-event.h"
#include "sd-json.h"
#include "errno-util.h"
#include "fd-util.h"
#include "io-util.h"
#include "pidref.h"
#include "process-util.h"
#include "qmp-client.h"
#include "socket-util.h"
#include "string-util.h"
#include "tests.h"
/* Mock QMP server: runs in the child process of a fork, communicates via one end of a socketpair. */
static void mock_qmp_write_json(int fd, sd_json_variant *v) {
_cleanup_free_ char *s = NULL;
ASSERT_OK(sd_json_variant_format(v, 0, &s));
ASSERT_NOT_NULL(strextend(&s, "\r\n"));
ASSERT_OK(loop_write(fd, s, SIZE_MAX));
}
static void mock_qmp_write_literal(int fd, const char *msg) {
ASSERT_OK(loop_write(fd, msg, SIZE_MAX));
ASSERT_OK(loop_write(fd, "\r\n", 2));
}
/* Read a command from the QMP client, verify it contains the expected command name, extract the id,
* and send a reply with that id. If reply_data is NULL, an empty return object is sent. */
static void mock_qmp_expect_and_reply(int fd, const char *expected_command, sd_json_variant *reply_data) {
_cleanup_free_ char *buf = NULL;
_cleanup_(sd_json_variant_unrefp) sd_json_variant *cmd = NULL, *reply_obj = NULL, *response = NULL;
buf = ASSERT_NOT_NULL(new(char, 4096));
ssize_t n = read(fd, buf, 4095);
assert_se(n > 0);
buf[n] = '\0';
ASSERT_OK(sd_json_parse(buf, 0, &cmd, NULL, NULL));
sd_json_variant *execute = ASSERT_NOT_NULL(sd_json_variant_by_key(cmd, "execute"));
ASSERT_STREQ(sd_json_variant_string(execute), expected_command);
sd_json_variant *id = ASSERT_NOT_NULL(sd_json_variant_by_key(cmd, "id"));
if (!reply_data)
ASSERT_OK(sd_json_variant_new_object(&reply_obj, NULL, 0));
ASSERT_OK(sd_json_buildo(
&response,
SD_JSON_BUILD_PAIR("return", SD_JSON_BUILD_VARIANT(reply_data ?: reply_obj)),
SD_JSON_BUILD_PAIR("id", SD_JSON_BUILD_VARIANT(id))));
mock_qmp_write_json(fd, response);
}
static _noreturn_ void mock_qmp_server(int fd) {
_cleanup_(sd_json_variant_unrefp) sd_json_variant *status_return = NULL;
/* Send QMP greeting */
mock_qmp_write_literal(fd,
"{\"QMP\": {\"version\": {\"qemu\": {\"micro\": 0, \"minor\": 2, \"major\": 9}}, \"capabilities\": [\"oob\"]}}");
/* Accept qmp_capabilities */
mock_qmp_expect_and_reply(fd, "qmp_capabilities", NULL);
/* Accept query-status, reply with running state */
ASSERT_OK(sd_json_buildo(
&status_return,
SD_JSON_BUILD_PAIR_BOOLEAN("running", true),
SD_JSON_BUILD_PAIR_STRING("status", "running")));
mock_qmp_expect_and_reply(fd, "query-status", status_return);
/* Accept stop */
mock_qmp_expect_and_reply(fd, "stop", NULL);
/* Send a STOP event */
mock_qmp_write_literal(fd,
"{\"event\": \"STOP\", \"timestamp\": {\"seconds\": 1234, \"microseconds\": 5678}}");
/* Accept cont */
mock_qmp_expect_and_reply(fd, "cont", NULL);
/* Close to trigger EOF */
safe_close(fd);
_exit(EXIT_SUCCESS);
}
/* Test helper: tracks an async QMP command result and signals completion. */
typedef struct {
sd_json_variant *result;
char *error_desc;
int error;
bool done;
} QmpTestResult;
static int on_test_result(
QmpClient *client,
sd_json_variant *result,
const char *error_desc,
int error,
void *userdata) {
QmpTestResult *t = ASSERT_PTR(userdata);
t->error = error;
if (result)
t->result = sd_json_variant_ref(result);
if (error_desc)
t->error_desc = strdup(error_desc);
t->done = true;
return 0;
}
/* Run the event loop until the test result callback fires. */
static void qmp_test_wait(sd_event *event, QmpTestResult *t) {
assert(event);
assert(t);
while (!t->done)
ASSERT_OK(sd_event_run(event, UINT64_MAX));
}
static void qmp_test_result_done(QmpTestResult *t) {
assert(t);
sd_json_variant_unref(t->result);
free(t->error_desc);
*t = (QmpTestResult) {};
}
static int test_event_callback(
QmpClient *client,
const char *event,
sd_json_variant *data,
void *userdata) {
bool *event_received = ASSERT_PTR(userdata);
/* We may also receive a synthetic SHUTDOWN event when the mock server closes the connection;
* only validate the STOP event we actually care about. */
if (streq(event, "STOP"))
*event_received = true;
return 0;
}
TEST(qmp_client_basic) {
_cleanup_(qmp_client_unrefp) QmpClient *client = NULL;
_cleanup_(sd_event_unrefp) sd_event *event = NULL;
_cleanup_(pidref_done) PidRef pid = PIDREF_NULL;
QmpTestResult t = {};
sd_json_variant *running, *status;
int qmp_fds[2];
int r;
ASSERT_OK(sd_event_new(&event));
ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds));
r = pidref_safe_fork("(mock-qmp)", FORK_DEATHSIG_SIGKILL|FORK_LOG, &pid);
ASSERT_OK(r);
if (r == 0) {
safe_close(qmp_fds[0]);
mock_qmp_server(qmp_fds[1]);
}
safe_close(qmp_fds[1]);
/* Connect then attach to event loop — handshake completes transparently
* inside the first call()/invoke(). */
ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0]));
ASSERT_OK(qmp_client_attach_event(client, event, SD_EVENT_PRIORITY_NORMAL));
/* Set event callback to catch STOP event during cont */
bool event_received = false;
qmp_client_bind_event(client, test_event_callback, &event_received);
/* Execute query-status */
ASSERT_OK(qmp_client_invoke(client, "query-status", NULL, on_test_result, &t));
qmp_test_wait(event, &t);
ASSERT_EQ(t.error, 0);
ASSERT_NOT_NULL(t.result);
running = ASSERT_NOT_NULL(sd_json_variant_by_key(t.result, "running"));
ASSERT_TRUE(sd_json_variant_boolean(running));
status = ASSERT_NOT_NULL(sd_json_variant_by_key(t.result, "status"));
ASSERT_STREQ(sd_json_variant_string(status), "running");
qmp_test_result_done(&t);
/* Execute stop */
ASSERT_OK(qmp_client_invoke(client, "stop", NULL, on_test_result, &t));
qmp_test_wait(event, &t);
ASSERT_EQ(t.error, 0);
qmp_test_result_done(&t);
/* Execute cont -- the STOP event should be dispatched by the IO callback */
ASSERT_OK(qmp_client_invoke(client, "cont", NULL, on_test_result, &t));
qmp_test_wait(event, &t);
ASSERT_EQ(t.error, 0);
qmp_test_result_done(&t);
/* Verify the STOP event was received */
ASSERT_TRUE(event_received);
/* Wait for child and verify clean exit */
siginfo_t si = {};
ASSERT_OK(pidref_wait_for_terminate(&pid, &si));
ASSERT_EQ(si.si_code, CLD_EXITED);
ASSERT_EQ(si.si_status, EXIT_SUCCESS);
}
TEST(qmp_client_eof) {
_cleanup_(qmp_client_unrefp) QmpClient *client = NULL;
_cleanup_(sd_event_unrefp) sd_event *event = NULL;
_cleanup_(pidref_done) PidRef pid = PIDREF_NULL;
QmpTestResult t = {};
int qmp_fds[2];
int r;
ASSERT_OK(sd_event_new(&event));
ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds));
r = pidref_safe_fork("(mock-qmp-eof)", FORK_DEATHSIG_SIGKILL|FORK_LOG, &pid);
ASSERT_OK(r);
if (r == 0) {
safe_close(qmp_fds[0]);
/* Send greeting and accept capabilities, then die */
mock_qmp_write_literal(qmp_fds[1],
"{\"QMP\": {\"version\": {\"qemu\": {\"micro\": 0, \"minor\": 0, \"major\": 9}}, \"capabilities\": []}}");
mock_qmp_expect_and_reply(qmp_fds[1], "qmp_capabilities", NULL);
/* Close immediately to trigger EOF */
safe_close(qmp_fds[1]);
_exit(EXIT_SUCCESS);
}
safe_close(qmp_fds[1]);
ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0]));
ASSERT_OK(qmp_client_attach_event(client, event, SD_EVENT_PRIORITY_NORMAL));
/* Executing a command should fail with a disconnect error because the server
* closed. The handshake may succeed or fail inside invoke() — either way the
* invoke itself or the async callback should report a disconnect. */
r = qmp_client_invoke(client, "query-status", NULL, on_test_result, &t);
if (r < 0)
ASSERT_TRUE(ERRNO_IS_NEG_DISCONNECT(r));
else {
qmp_test_wait(event, &t);
ASSERT_TRUE(ERRNO_IS_NEG_DISCONNECT(t.error));
qmp_test_result_done(&t);
}
siginfo_t si = {};
ASSERT_OK(pidref_wait_for_terminate(&pid, &si));
ASSERT_EQ(si.si_code, CLD_EXITED);
ASSERT_EQ(si.si_status, EXIT_SUCCESS);
}
/* Read one QMP command from fd (one recvmsg, expecting it fits in the buffer for typical
* test commands). Returns the number of SCM_RIGHTS fds that arrived attached to the read,
* stores the first received fd in *ret_received_fd (or -EBADF if none) and closes any extras,
* and parses the JSON into *ret_cmd. */
static size_t mock_qmp_recv_command(int fd, sd_json_variant **ret_cmd, int *ret_received_fd) {
char buf[4096];
char ctrl[CMSG_SPACE(sizeof(int) * 4)];
struct iovec iov = { .iov_base = buf, .iov_len = sizeof(buf) - 1 };
struct msghdr mh = {
.msg_iov = &iov, .msg_iovlen = 1,
.msg_control = ctrl, .msg_controllen = sizeof(ctrl),
};
size_t n_fds = 0;
int received_fd = -EBADF;
ssize_t n = recvmsg(fd, &mh, MSG_CMSG_CLOEXEC);
assert_se(n > 0);
buf[n] = '\0';
struct cmsghdr *cmsg;
CMSG_FOREACH(cmsg, &mh) {
if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS)
continue;
size_t k = (cmsg->cmsg_len - CMSG_LEN(0)) / sizeof(int);
int *fds = (int*) CMSG_DATA(cmsg);
for (size_t i = 0; i < k; i++) {
if (received_fd < 0)
received_fd = fds[i];
else
safe_close(fds[i]);
}
n_fds += k;
}
ASSERT_OK(sd_json_parse(buf, 0, ret_cmd, NULL, NULL));
if (ret_received_fd)
*ret_received_fd = received_fd;
else if (received_fd >= 0)
safe_close(received_fd);
return n_fds;
}
/* Mock QMP server for the fd-on-first-invoke regression. Drives the wire dance:
* greeting → (recv qmp_capabilities, expect 0 fds) → reply →
* (recv add-fd, expect exactly 1 fd) → reply
* Asserts the cmsg fd counts directly so a regression flips the child to
* exit_failure and the parent test fails on the wait-for-terminate. */
static _noreturn_ void mock_qmp_server_fd_first(int fd) {
_cleanup_(sd_json_variant_unrefp) sd_json_variant *cap_cmd = NULL,
*addfd_cmd = NULL,
*cap_reply = NULL,
*addfd_return = NULL,
*addfd_reply = NULL;
size_t n_fds;
int received_fd = -EBADF;
/* Greeting */
mock_qmp_write_literal(fd,
"{\"QMP\": {\"version\": {\"qemu\": {\"micro\": 0, \"minor\": 0, \"major\": 9}}, \"capabilities\": []}}");
/* Receive qmp_capabilities — must arrive with NO fds attached. */
n_fds = mock_qmp_recv_command(fd, &cap_cmd, /* ret_received_fd= */ NULL);
ASSERT_EQ(n_fds, (size_t) 0);
ASSERT_STREQ(sd_json_variant_string(sd_json_variant_by_key(cap_cmd, "execute")), "qmp_capabilities");
sd_json_variant *cap_id = ASSERT_NOT_NULL(sd_json_variant_by_key(cap_cmd, "id"));
ASSERT_OK(sd_json_buildo(
&cap_reply,
SD_JSON_BUILD_PAIR("return", SD_JSON_BUILD_EMPTY_OBJECT),
SD_JSON_BUILD_PAIR("id", SD_JSON_BUILD_VARIANT(cap_id))));
mock_qmp_write_json(fd, cap_reply);
/* Receive add-fd — must arrive with EXACTLY ONE fd attached. */
n_fds = mock_qmp_recv_command(fd, &addfd_cmd, &received_fd);
ASSERT_EQ(n_fds, (size_t) 1);
ASSERT_TRUE(received_fd >= 0);
ASSERT_STREQ(sd_json_variant_string(sd_json_variant_by_key(addfd_cmd, "execute")), "add-fd");
safe_close(received_fd);
sd_json_variant *addfd_id = ASSERT_NOT_NULL(sd_json_variant_by_key(addfd_cmd, "id"));
ASSERT_OK(sd_json_buildo(
&addfd_return,
SD_JSON_BUILD_PAIR_UNSIGNED("fdset-id", 0),
SD_JSON_BUILD_PAIR_UNSIGNED("fd", 42)));
ASSERT_OK(sd_json_buildo(
&addfd_reply,
SD_JSON_BUILD_PAIR("return", SD_JSON_BUILD_VARIANT(addfd_return)),
SD_JSON_BUILD_PAIR("id", SD_JSON_BUILD_VARIANT(addfd_id))));
mock_qmp_write_json(fd, addfd_reply);
safe_close(fd);
_exit(EXIT_SUCCESS);
}
/* Regression: pass an fd in the very first qmp_client_invoke() against a fresh client
* (lazy-bootstrap state, handshake not yet done). The previous push_fd+invoke split would
* stage the fd on the stream BEFORE qmp_client_ensure_running() drove the handshake; the
* handshake's qmp_capabilities enqueue would then steal the staged fd onto its own
* sendmsg. The new QmpClientArgs API stages fds inside invoke AFTER ensure_running, so
* the fd lands on add-fd's sendmsg as it should. */
TEST(qmp_client_first_invoke_with_fd) {
_cleanup_(qmp_client_unrefp) QmpClient *client = NULL;
_cleanup_(sd_event_unrefp) sd_event *event = NULL;
_cleanup_(pidref_done) PidRef pid = PIDREF_NULL;
_cleanup_(sd_json_variant_unrefp) sd_json_variant *args = NULL;
_cleanup_close_ int fd_to_pass = -EBADF;
QmpTestResult t = {};
int qmp_fds[2];
int r;
ASSERT_OK(sd_event_new(&event));
ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds));
r = pidref_safe_fork("(mock-qmp-fd-first)", FORK_DEATHSIG_SIGKILL|FORK_LOG, &pid);
ASSERT_OK(r);
if (r == 0) {
safe_close(qmp_fds[0]);
mock_qmp_server_fd_first(qmp_fds[1]);
}
safe_close(qmp_fds[1]);
/* Open a real fd to pass — /dev/null is universally available. */
fd_to_pass = open("/dev/null", O_RDWR|O_CLOEXEC);
ASSERT_OK(fd_to_pass);
ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0]));
ASSERT_OK(qmp_client_attach_event(client, event, SD_EVENT_PRIORITY_NORMAL));
/* Build add-fd args. The fdset-id value is irrelevant — the mock server only
* cares that the fd arrived with the correct sendmsg. */
ASSERT_OK(sd_json_buildo(&args, SD_JSON_BUILD_PAIR_UNSIGNED("fdset-id", 0)));
/* THIS is the previously-broken pattern: very first invoke against the client,
* carrying an fd, with the handshake still pending. */
ASSERT_OK(qmp_client_invoke(client, "add-fd",
QMP_CLIENT_ARGS_FD(args, TAKE_FD(fd_to_pass)),
on_test_result, &t));
qmp_test_wait(event, &t);
ASSERT_EQ(t.error, 0);
ASSERT_NOT_NULL(t.result);
qmp_test_result_done(&t);
/* Wait for the mock server child. If it received fds in the wrong order it
* exited via the test-assertion failure path and si.si_status will be non-zero. */
siginfo_t si = {};
ASSERT_OK(pidref_wait_for_terminate(&pid, &si));
ASSERT_EQ(si.si_code, CLD_EXITED);
ASSERT_EQ(si.si_status, EXIT_SUCCESS);
}
/* Regression: when qmp_client_invoke() fails before stage_fds runs (e.g.
* ensure_running() returns -ENOTCONN because the peer closed mid-handshake), the
* caller-supplied fds — already TAKE_FD()'d through QMP_CLIENT_ARGS_FD() — must be
* closed inside invoke. Otherwise they leak. */
TEST(qmp_client_invoke_failure_closes_fds) {
_cleanup_(qmp_client_unrefp) QmpClient *client = NULL;
_cleanup_(sd_json_variant_unrefp) sd_json_variant *args = NULL;
_cleanup_close_ int fd_to_pass = -EBADF;
QmpTestResult t = {};
int qmp_fds[2];
int saved_fd_value;
ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds));
/* Close the peer end immediately so ensure_running()'s read sees EOF and
* the client transitions straight to DISCONNECTED inside the first invoke. */
safe_close(qmp_fds[1]);
ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0]));
/* Deliberately do NOT attach to an event loop — invoke uses ensure_running()'s
* synchronous process+wait pump for the handshake. */
fd_to_pass = open("/dev/null", O_RDWR|O_CLOEXEC);
ASSERT_OK(fd_to_pass);
saved_fd_value = fd_to_pass; /* remember the int value for the closed-check */
ASSERT_OK(sd_json_buildo(&args, SD_JSON_BUILD_PAIR_UNSIGNED("fdset-id", 0)));
/* invoke must fail because the peer is gone. The TAKE_FD inside the macro
* has already zeroed our local fd_to_pass; if invoke leaked the fd here,
* the fd would stay open in our process. */
int r = qmp_client_invoke(client, "add-fd",
QMP_CLIENT_ARGS_FD(args, TAKE_FD(fd_to_pass)),
on_test_result, &t);
ASSERT_TRUE(r < 0);
ASSERT_TRUE(ERRNO_IS_NEG_DISCONNECT(r));
/* fd_to_pass should now be -EBADF (TAKE_FD'd) and the underlying kernel fd
* should have been closed by the qmp_client_args_close_fds cleanup in
* qmp_client_invoke(). fcntl on the old int returns EBADF only if the slot
* is genuinely free. */
ASSERT_EQ(fd_to_pass, -EBADF);
ASSERT_EQ(fcntl(saved_fd_value, F_GETFD), -1);
ASSERT_EQ(errno, EBADF);
}
TEST(qmp_schema_has_member) {
_cleanup_(sd_json_variant_unrefp) sd_json_variant *schema = NULL;
/* QEMU introspection uses opaque numeric type ids ("0", "1", ...) — only member names are
* the actual QAPI strings. Verify we walk all object entries and find the member by name. */
ASSERT_OK(sd_json_build(&schema,
SD_JSON_BUILD_ARRAY(
SD_JSON_BUILD_OBJECT(
SD_JSON_BUILD_PAIR_STRING("name", "0"),
SD_JSON_BUILD_PAIR_STRING("meta-type", "object"),
SD_JSON_BUILD_PAIR("members", SD_JSON_BUILD_ARRAY(
SD_JSON_BUILD_OBJECT(
SD_JSON_BUILD_PAIR_STRING("name", "offset"),
SD_JSON_BUILD_PAIR_STRING("type", "int"))))),
SD_JSON_BUILD_OBJECT(
SD_JSON_BUILD_PAIR_STRING("name", "SomeEnum"),
SD_JSON_BUILD_PAIR_STRING("meta-type", "enum")),
SD_JSON_BUILD_OBJECT(
SD_JSON_BUILD_PAIR_STRING("name", "1"),
SD_JSON_BUILD_PAIR_STRING("meta-type", "object"),
SD_JSON_BUILD_PAIR("members", SD_JSON_BUILD_ARRAY(
SD_JSON_BUILD_OBJECT(
SD_JSON_BUILD_PAIR_STRING("name", "lazy-refcounts"),
SD_JSON_BUILD_PAIR_STRING("type", "bool")),
SD_JSON_BUILD_OBJECT(
SD_JSON_BUILD_PAIR_STRING("name", "discard-no-unref"),
SD_JSON_BUILD_PAIR_STRING("type", "bool"))))))));
ASSERT_TRUE(qmp_schema_has_member(schema, "discard-no-unref"));
ASSERT_TRUE(qmp_schema_has_member(schema, "offset"));
ASSERT_FALSE(qmp_schema_has_member(schema, "definitely-not-a-real-field"));
ASSERT_FALSE(qmp_schema_has_member(NULL, "discard-no-unref"));
}
static int intro(void) {
/* Ignore SIGPIPE so that write() to a closed socket returns EPIPE instead of killing us */
ASSERT_TRUE(signal(SIGPIPE, SIG_IGN) != SIG_ERR);
return 0;
}
DEFINE_TEST_MAIN_FULL(LOG_DEBUG, intro, NULL);