diff --git a/CMakeLists.txt b/CMakeLists.txt index 0cbc5bc..2367c62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) # Single source of truth for the version: CI passes -DVMSIG_VERSION=${TAG#v}, so the project # version (-> libvgpu-perception SONAME/.so version) and the .deb version come from one tag. -set(VMSIG_VERSION "0.3.7" CACHE STRING "Release version (MAJOR.MINOR.PATCH); CI passes the tag") +set(VMSIG_VERSION "0.3.8" CACHE STRING "Release version (MAJOR.MINOR.PATCH); CI passes the tag") project(vmsig VERSION ${VMSIG_VERSION} LANGUAGES C) set(CMAKE_C_STANDARD 17) @@ -269,6 +269,13 @@ target_link_libraries(vmsig_inputobstest PRIVATE vmsig Threads::Threads) target_compile_options(vmsig_inputobstest PRIVATE -Wall -Wextra) add_test(NAME inputobs COMMAND vmsig_inputobstest) +add_executable(vmsig_uinputlayouttest src/test/test_uinputlayout.c) +target_link_libraries(vmsig_uinputlayouttest PRIVATE vmsig) +target_include_directories(vmsig_uinputlayouttest PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src/si/input/include) +target_compile_options(vmsig_uinputlayouttest PRIVATE -Wall -Wextra) +add_test(NAME uinputlayout COMMAND vmsig_uinputlayouttest) + add_executable(vmsig_memwritetest src/test/test_memwrite.c) target_link_libraries(vmsig_memwritetest PRIVATE vmsig Threads::Threads) target_include_directories(vmsig_memwritetest PRIVATE diff --git a/include/vmctl.h b/include/vmctl.h index 04e8f6c..3a24134 100644 --- a/include/vmctl.h +++ b/include/vmctl.h @@ -38,6 +38,12 @@ typedef struct { vmctl_t* vmctl_open (const vmctl_config* cfg); /* NULL on error */ void vmctl_close(vmctl_t* v); /* safe on NULL */ +/* Copy the host evdev node paths of the created uinput devices (UINPUT driver only). + * a[] receives device A, b[] receives device B (empty if not VMCTL_PTR_BOTH); each buffer + * must be >=64 bytes. Returns the count of non-empty paths filled (0/1/2), or -1 if the + * handle's driver is not UINPUT. Paths are valid while the handle is open. */ +int vmctl_uinput_evdev(vmctl_t* v, char a[64], char b[64]); + /* ===== Input constants ===== */ #define VMCTL_ABS_MAX 32767 /* abs coordinates 0..VMCTL_ABS_MAX */ #define VMCTL_AXIS_X 0 diff --git a/src/adapter/input/include/input.h b/src/adapter/input/include/input.h index f777281..ebdff44 100644 --- a/src/adapter/input/include/input.h +++ b/src/adapter/input/include/input.h @@ -3,11 +3,18 @@ /* Private config of the input adapter (vmctl, in-tree at src/si/input/). cfg==NULL or * stub!=0 => stub mode (ack without actuation). stub==0 opens vmctl_open() and actuates for - * real. Injection is ALWAYS uinput (orphaned host uinput + external QEMU input-linux); - * qmp_path is kept for the SERVICE path (power/lifecycle via vmctl QMP), not for injection. */ + * real. Injection is ALWAYS uinput; the created evdev nodes are forwarded into the guest by an + * input-linux QMP object that the vmhost seam adds over its own connection (this adapter only + * publishes the evdev paths, it never touches QMP). qmp_path is kept for the SERVICE path + * (power/lifecycle via vmctl QMP), not for injection. */ typedef struct { int stub; const char* qmp_path; /* for power/lifecycle (vmctl QMP); NOT input injection */ + /* On a real attach the adapter writes the uinput evdev node paths here (>=64 bytes each) + * so the vmhost seam can bridge them via input-linux. NULL => not published; B is "" when + * there is no second device. Buffers belong to the caller and outlive the adapter. */ + char* out_evdev_a; + char* out_evdev_b; } vmsig_input_cfg; /* Input event codes/contract are PUBLIC: vmsig_input / vmsig_input_kind in diff --git a/src/adapter/input/input.c b/src/adapter/input/input.c index 2e18f49..37f042f 100644 --- a/src/adapter/input/input.c +++ b/src/adapter/input/input.c @@ -39,6 +39,8 @@ struct vmsig_adapter { vmsig_worker* worker; const char* qmp_path; /* borrowed from cfg (valid through attach); SERVICE power/lifecycle */ vmctl_t* vmctl; /* NULL in stub mode (cfg.stub) — no actuator opened */ + char* out_evdev_a; /* borrowed home for the uinput evdev path of A; NULL = not published */ + char* out_evdev_b; /* likewise for B; "" written when there is no second device */ }; static int input_job(void* user, const void* reqp, void* resp) { @@ -93,7 +95,11 @@ static vmsig_adapter* in_open(const void* cfg, uint32_t endpoint) { if (!a) return NULL; a->endpoint = endpoint; a->stub = c ? c->stub : 1; - if (c) a->qmp_path = c->qmp_path; /* carry to attach (cfg not passed there); SERVICE power */ + if (c) { + a->qmp_path = c->qmp_path; /* carry to attach (cfg not passed there); SERVICE power */ + a->out_evdev_a = c->out_evdev_a; + a->out_evdev_b = c->out_evdev_b; + } return a; } @@ -104,9 +110,10 @@ static int in_attach(vmsig_adapter* a, const vmsig_emit* emit, vmsig_fd_reg* reg if (!a->worker) return -1; if (!a->stub) { - /* armed: open the actuator. Injection is ALWAYS uinput (orphaned host uinput + external - * QEMU input-linux). PTR_BOTH gives both pointer forms a device (A=abs tablet, B=rel - * mouse) — the contract now promises both MOVE_ABS and MOVE_REL, so neither may be + /* armed: open the actuator. Injection is ALWAYS uinput; the resulting evdev nodes are + * forwarded into the guest by the vmhost seam's input-linux object (published below). + * PTR_BOTH gives both pointer forms a device (A=kbd+abs tablet, B=rel mouse+buttons+ + * wheel) — the contract now promises both MOVE_ABS and MOVE_REL, so neither may be * disabled. qmp_path serves the SERVICE power/lifecycle path, not input injection. */ vmctl_config vcfg; memset(&vcfg, 0, sizeof vcfg); @@ -117,6 +124,15 @@ static int in_attach(vmsig_adapter* a, const vmsig_emit* emit, vmsig_fd_reg* reg vcfg.uinput_id = NULL; /* built-in HID identity defaults */ a->vmctl = vmctl_open(&vcfg); if (!a->vmctl) { vmsig_worker_free(a->worker); a->worker = NULL; return -1; } + + /* Publish the created evdev node paths into the stable per-endpoint home so the vmhost + * seam can bridge them via input-linux. The seam reads these after both attaches. */ + if (a->out_evdev_a || a->out_evdev_b) { + char ea[64] = {0}, eb[64] = {0}; + vmctl_uinput_evdev(a->vmctl, ea, eb); + if (a->out_evdev_a) memcpy(a->out_evdev_a, ea, sizeof ea); + if (a->out_evdev_b) memcpy(a->out_evdev_b, eb, sizeof eb); + } } reg[0].fd = vmsig_worker_evfd(a->worker); diff --git a/src/adapter/vmhost/include/vmhost.h b/src/adapter/vmhost/include/vmhost.h index 6cea5e3..9069008 100644 --- a/src/adapter/vmhost/include/vmhost.h +++ b/src/adapter/vmhost/include/vmhost.h @@ -8,6 +8,13 @@ typedef struct { int stub; const char* qmp_path; + /* Host->guest input bridge: evdev node paths of the uinput devices (published by the input + * seam). When non-NULL/non-empty, on reaching READY the seam adds an input-linux QMP object + * forwarding them into the guest (A=kbd+abs with grab_all, B=mouse). NULL/"" => no bridge + * (stub/tests are fail-closed). Pointers are borrowed from the stable per-endpoint home and + * outlive the adapter. */ + const char* bridge_evdev_a; + const char* bridge_evdev_b; } vmsig_vmhost_cfg; #endif /* VMSIG_VMHOST_H */ diff --git a/src/adapter/vmhost/vmhost.c b/src/adapter/vmhost/vmhost.c index 1465f2a..25e6e58 100644 --- a/src/adapter/vmhost/vmhost.c +++ b/src/adapter/vmhost/vmhost.c @@ -27,6 +27,12 @@ enum { ST_STUB = 0, ST_CONNECTING, ST_NEGOTIATING, ST_READY, ST_DEAD }; +/* Internal pend op tags for the host->guest input bridge (object_add/object_del). These are + * NOT VMSIG_VMOP_* (which occupy 0..5) and never reach control: bridge setup is the seam's own + * VM-substrate infrastructure, so its replies are handled silently (no ACK, no VM_LIFECYCLE). */ +#define VH_OP_BRIDGE_ADD 0x80 +#define VH_OP_BRIDGE_DEL 0x81 + typedef struct { uint32_t id, origin, corr; uint8_t op; int used; } pend_ent; struct vmsig_adapter { @@ -41,6 +47,13 @@ struct vmsig_adapter { size_t buflen; uint32_t next_id; pend_ent pend[VMHOST_MAX_PENDING]; + /* Host->guest input bridge: evdev paths borrowed from the stable per-endpoint home; + * NULL/"" => no bridge. The *_up flags track which input-linux objects were added so + * teardown can object_del exactly those. */ + const char* bridge_evdev_a; + const char* bridge_evdev_b; + int bridge_a_up; + int bridge_b_up; }; /* ---- minimal QMP line parse (top-level keys only; full JSON — deferred) ---- */ @@ -101,6 +114,57 @@ static pend_ent* pend_find(struct vmsig_adapter* a, uint32_t id) { return NULL; } +/* ---- host->guest input bridge (input-linux object_add/object_del) ---- */ + +/* Neutral, per-endpoint object ids — no private paths or names; evdev comes from cfg. */ +static void bridge_id(char* out, size_t cap, uint32_t ep, char ab) { + snprintf(out, cap, "vmsig-in-%c-%u", ab, ep); +} + +/* Add one input-linux object forwarding an evdev node into the guest. grab_all toggles the + * device-grab for every input-linux on this endpoint (set on A only — one is enough). The + * reply is correlated through the existing pend[] table under VH_OP_BRIDGE_ADD and consumed + * silently. Returns 0 on a queued write, -1 on backpressure / write failure. */ +static int bridge_add(struct vmsig_adapter* a, char ab, const char* evdev, int grab_all) { + pend_ent* p = pend_alloc(a); + if (!p) return -1; + char id[32]; + bridge_id(id, sizeof id, a->endpoint, ab); + uint32_t qid = ++a->next_id; + char line[320]; + int len = snprintf(line, sizeof line, + "{\"execute\":\"object_add\",\"arguments\":{\"qom-type\":\"input-linux\"," + "\"id\":\"%s\",\"evdev\":\"%s\"%s},\"id\":%u}\r\n", + id, evdev, grab_all ? ",\"grab_all\":true" : "", qid); + p->used = 1; p->id = qid; p->origin = 0; p->corr = 0; p->op = VH_OP_BRIDGE_ADD; + ssize_t r = write(a->fd, line, (size_t)len); + if (r != (ssize_t)len) { p->used = 0; return -1; } + return 0; +} + +/* Best-effort object_del fired on teardown before the fd closes (see vh_close). No reply is + * awaited; QEMU also drops these objects when the VM powers off, so del matters only on a + * detach without power-off (daemon restart / endpoint move). */ +static void bridge_del_fire(struct vmsig_adapter* a, char ab) { + char id[32]; + bridge_id(id, sizeof id, a->endpoint, ab); + char line[160]; + int len = snprintf(line, sizeof line, + "{\"execute\":\"object_del\",\"arguments\":{\"id\":\"%s\"}}\r\n", id); + ssize_t r = write(a->fd, line, (size_t)len); + (void)r; +} + +/* Add the bridge objects upon reaching READY. A is added with grab_all; B (mouse) without. */ +static void bridge_on_ready(struct vmsig_adapter* a) { + if (a->bridge_evdev_a && a->bridge_evdev_a[0]) { + if (bridge_add(a, 'a', a->bridge_evdev_a, 1) == 0) a->bridge_a_up = 1; + } + if (a->bridge_evdev_b && a->bridge_evdev_b[0]) { + if (bridge_add(a, 'b', a->bridge_evdev_b, 0) == 0) a->bridge_b_up = 1; + } +} + /* ---- emission of neutral UP events ---- */ static void emit_vm(struct vmsig_adapter* a, uint32_t state, uint32_t origin, uint32_t corr) { vmsig_vm_state vs = { state, 0 }; @@ -142,7 +206,11 @@ static void handle_line(struct vmsig_adapter* a, const char* line) { } break; case ST_NEGOTIATING: - if (strstr(line, "\"return\"")) { a->st = ST_READY; emit_seam(a, VMSIG_EV_SEAM_UP); } + if (strstr(line, "\"return\"")) { + a->st = ST_READY; + emit_seam(a, VMSIG_EV_SEAM_UP); + bridge_on_ready(a); /* forward the host uinput evdev nodes into the guest */ + } break; case ST_READY: if (strstr(line, "\"event\"")) { @@ -155,7 +223,12 @@ static void handle_line(struct vmsig_adapter* a, const char* line) { long id = jnum(line, "\"id\""); pend_ent* p = id >= 0 ? pend_find(a, (uint32_t)id) : NULL; if (p) { - if (p->op == VMSIG_VMOP_QUERY && strstr(line, "\"return\"")) { + if (p->op == VH_OP_BRIDGE_ADD || p->op == VH_OP_BRIDGE_DEL) { + /* Bridge infrastructure: never surfaces to control. Log a failed add so the + * stand can see it; otherwise silent. */ + if (p->op == VH_OP_BRIDGE_ADD && strstr(line, "\"error\"")) + fprintf(stderr, "vmsig vmhost: input-linux object_add failed: %s\n", line); + } else if (p->op == VMSIG_VMOP_QUERY && strstr(line, "\"return\"")) { char stbuf[32]; uint32_t s = VMSIG_VM_UNKNOWN; if (jstr(line, "\"status\"", stbuf, sizeof stbuf)) s = status_state(stbuf); emit_vm(a, s, p->origin, p->corr); /* addressed reply */ @@ -184,6 +257,7 @@ static vmsig_adapter* vh_open(const void* cfg, uint32_t endpoint) { a->endpoint = endpoint; a->qmp_path = (c && c->qmp_path && c->qmp_path[0]) ? c->qmp_path : NULL; a->stub = (a->qmp_path == NULL); /* path given => armed, otherwise stub */ + if (c) { a->bridge_evdev_a = c->bridge_evdev_a; a->bridge_evdev_b = c->bridge_evdev_b; } a->fd = -1; a->cur = VMSIG_VM_RUNNING; return a; @@ -300,6 +374,12 @@ static int vh_submit(vmsig_adapter* a, const vmsig_event* ev) { static void vh_close(vmsig_adapter* a) { if (!a) return; + /* Best-effort teardown of the input-linux bridge while the connection is still live: fire + * object_del for the objects we added, with no round-trip (the fd closes right after). */ + if (a->st == ST_READY && a->fd >= 0) { + if (a->bridge_a_up) bridge_del_fire(a, 'a'); + if (a->bridge_b_up) bridge_del_fire(a, 'b'); + } if (a->fd >= 0) close(a->fd); free(a); } diff --git a/src/discovery/discovery.c b/src/discovery/discovery.c index 8d7138b..25b4328 100644 --- a/src/discovery/discovery.c +++ b/src/discovery/discovery.c @@ -56,6 +56,10 @@ struct vmsig_discovery { * keep pointers, and detach is deferred, so this must outlive the candidate. Overwritten * only on the NEXT attach to the endpoint, which never races a still-open prior adapter. */ vmsig_host_facts ep_facts[VMSIG_SLOT_COUNT]; + /* Stable per-endpoint home for the uinput evdev paths of the input bridge. The input seam + * writes these at attach; the vmhost seam borrows them to add input-linux objects. Same + * lifetime discipline as ep_facts (outlives the deferred adapter reap). */ + struct { char evdev_a[64]; char evdev_b[64]; } ep_bridge[VMSIG_SLOT_COUNT]; }; static uint64_t now_ns(void) { @@ -265,17 +269,27 @@ static void bootstrap_scan(vmsig_discovery* d) { static int default_attach(void* ud, vmsig_core* core, uint32_t vmid, uint32_t endpoint, const vmsig_host_facts* f) { - (void)ud; (void)vmid; + (void)vmid; + vmsig_discovery* d = ud; /* default sink carries the discovery handle (ep_bridge home) */ + char* ev_a = d ? d->ep_bridge[endpoint].evdev_a : NULL; + char* ev_b = d ? d->ep_bridge[endpoint].evdev_b : NULL; + if (d) { ev_a[0] = '\0'; ev_b[0] = '\0'; } /* clear stale paths from a prior attach */ + vmsig_memctx_cfg mc; memset(&mc, 0, sizeof mc); mc.stub = 0; mc.ram_path = f->ram_path; mc.low = f->low; mc.ro_fd = -1; - vmsig_vmhost_cfg vh; memset(&vh, 0, sizeof vh); - vh.stub = 0; vh.qmp_path = f->qmp_path; vmsig_input_cfg in; memset(&in, 0, sizeof in); - in.stub = 0; in.qmp_path = NULL; /* input is uinput; power/lifecycle via the vmhost seam */ + /* input is uinput; power/lifecycle via the vmhost seam. The adapter publishes its uinput + * evdev paths into ep_bridge so the vmhost seam can forward them via input-linux. */ + in.stub = 0; in.qmp_path = NULL; in.out_evdev_a = ev_a; in.out_evdev_b = ev_b; + vmsig_vmhost_cfg vh; memset(&vh, 0, sizeof vh); + /* vmhost borrows the (now-populated) evdev paths to add the input-linux bridge at READY. */ + vh.stub = 0; vh.qmp_path = f->qmp_path; vh.bridge_evdev_a = ev_a; vh.bridge_evdev_b = ev_b; + /* Order matters: input attaches BEFORE vmhost so the evdev paths are written into ep_bridge + * before the vmhost seam reads them (READY is async and always later than both attaches). */ if (vmsig_core_add_adapter(core, vmsig_memctx_ops(), &mc, endpoint) < 0) goto fail; - if (vmsig_core_add_adapter(core, vmsig_vmhost_ops(), &vh, endpoint) < 0) goto fail; if (vmsig_core_add_adapter(core, vmsig_input_ops(), &in, endpoint) < 0) goto fail; + if (vmsig_core_add_adapter(core, vmsig_vmhost_ops(), &vh, endpoint) < 0) goto fail; return 0; fail: vmsig_core_detach_endpoint(core, endpoint); /* roll back any partial trio (deferred) */ @@ -319,7 +333,7 @@ vmsig_discovery* vmsig_discovery_new(vmsig_core* core, pve_conf ? pve_conf : "/etc/pve/qemu-server", qmp_dir ? qmp_dir : "/var/run/qemu-server"); if (sink) d->sink = *sink; - else { d->sink.attach = default_attach; d->sink.detach = default_detach; d->sink.ud = NULL; } + else { d->sink.attach = default_attach; d->sink.detach = default_detach; d->sink.ud = d; } slot_load(&d->slots, d->persist ? d->slots_path : NULL); diff --git a/src/si/input/include/driver.h b/src/si/input/include/driver.h index 8dfd5ee..8140f18 100644 --- a/src/si/input/include/driver.h +++ b/src/si/input/include/driver.h @@ -23,6 +23,8 @@ struct vmctl { int ui_fd_a; /* uinput driver: device A; -1 for QMP */ int ui_fd_b; /* uinput driver: device B (BOTH); -1 */ int ptr_mode; /* uinput driver: VMCTL_PTR_*; 0 for QMP */ + char ui_evdev_a[64]; /* uinput driver: /dev/input/eventN of A ("" if none) */ + char ui_evdev_b[64]; /* uinput driver: /dev/input/eventN of B ("" if none) */ /* Held-state receipt: key/btn down-bits as THIS handle last actuated them * (not guest truth). Written only after a successful send in diff --git a/src/si/input/include/uinput_layout.h b/src/si/input/include/uinput_layout.h new file mode 100644 index 0000000..1840d66 --- /dev/null +++ b/src/si/input/include/uinput_layout.h @@ -0,0 +1,41 @@ +#ifndef VMCTL_UINPUT_LAYOUT_H +#define VMCTL_UINPUT_LAYOUT_H +#include "vmctl.h" + +/* uinput_layout.h — DECLARATIVE capability split for the uinput driver, kept pure (no ioctl) + * so it is unit-testable without /dev/uinput. The roles are derived from ptr_mode, NOT inferred + * as a side effect of rel_motion; the hot path's button/wheel carrier follows the same rule. + * + * Layout: device A always carries the keyboard. Mouse buttons + scroll wheel ride the device + * carrying the relative pointer (B in BOTH, A in REL-only); with no relative pointer (ABS-only) + * they fall back to A. So in BOTH: A=keyboard+abs, B=rel+buttons+wheel. */ + +typedef struct { + int present; /* 1 if this device is created for the given ptr_mode */ + int rel_motion; /* advertise relative X/Y (else absolute X/Y) */ + int want_keyboard; /* advertise the keyboard keymap */ + int want_buttons; /* advertise the 8 mouse buttons */ + int want_wheel; /* advertise REL_WHEEL / REL_HWHEEL */ +} uinput_role; + +/* Fill role_a/role_b from ptr_mode (VMCTL_PTR_*). Sets *btn_on_b to 1 when the button/wheel + * carrier on the hot path is device B (only in PTR_BOTH). role_b.present is 0 unless BOTH. */ +static inline void vmctl_uinput_layout(int ptr_mode, uinput_role* role_a, uinput_role* role_b, + int* btn_on_b) { + int both = (ptr_mode == VMCTL_PTR_BOTH); + role_a->present = 1; + role_a->rel_motion = (ptr_mode == VMCTL_PTR_REL); + role_a->want_keyboard = 1; + role_a->want_buttons = !both; /* B carries buttons when there are two devices */ + role_a->want_wheel = !both; + + role_b->present = both; + role_b->rel_motion = 1; + role_b->want_keyboard = 0; + role_b->want_buttons = both; + role_b->want_wheel = both; + + if (btn_on_b) *btn_on_b = both; +} + +#endif /* VMCTL_UINPUT_LAYOUT_H */ diff --git a/src/si/input/linux/uinput_driver.c b/src/si/input/linux/uinput_driver.c index d33ff5b..282957c 100644 --- a/src/si/input/linux/uinput_driver.c +++ b/src/si/input/linux/uinput_driver.c @@ -9,12 +9,21 @@ * (device_add) and undone at close (device_del). It is NOT a per-event * mechanism and lives entirely in the hotplug helpers below. * - * uinput != virtio. Without qmp_path/input_bus the uinput device is created - * orphaned (an external layer may forward it). The driver switches on - * vmctl_ev_kind (never on magic numbers). */ + * uinput != virtio. The created uinput evdev nodes are forwarded into the guest + * by an input-linux QMP object that the signaling vmhost seam adds over its own + * connection (the evdev paths are exported via vmctl_uinput_evdev). The driver + * switches on vmctl_ev_kind (never on magic numbers). + * + * Capability layout (VMCTL_PTR_BOTH): keyboard + absolute pointer on device A; + * relative pointer + mouse buttons + scroll wheel on device B. Buttons/wheel ride + * the device carrying the relative pointer (B in BOTH, A in REL-only); with no + * relative pointer (ABS-only) they fall back to A. This split is DECLARATIVE: the + * roles (want_buttons/want_wheel/rel_motion) are passed into uinput_create, not + * inferred from rel_motion as a side effect. */ #include "driver.h" #include "keymap.h" +#include "uinput_layout.h" #include #include @@ -50,29 +59,38 @@ static void emit(int fd, uint16_t type, uint16_t code, int32_t val) { static void syn(int fd) { emit(fd, EV_SYN, SYN_REPORT, 0); } -static int uinput_create(int rel_motion, const vmctl_uinput_id* id, const char* name, char evdev[64]) { +/* The declarative per-device role (uinput_role) and the ptr_mode -> A/B split live in + * uinput_layout.h so the layout is unit-testable without /dev/uinput. */ +static int uinput_create(const uinput_role* role, const vmctl_uinput_id* id, + const char* name, char evdev[64]) { int fd = open("/dev/uinput", O_RDWR | O_CLOEXEC); if (fd < 0) return -1; ioctl(fd, UI_SET_EVBIT, EV_SYN); ioctl(fd, UI_SET_EVBIT, EV_KEY); - /* Keyboard keybits come from the single source of truth: every key in - * VMCTL_KEYS, so a key in the table always works through uinput too. */ - for (int i = 0; i < VMCTL_KEYS_LEN; i++) - ioctl(fd, UI_SET_KEYBIT, VMCTL_KEYS[i].evdev); - for (int b = 0; b < 8; b++) - ioctl(fd, UI_SET_KEYBIT, (int)BTN_CODES[b]); + if (role->want_keyboard) { + /* Keyboard keybits come from the single source of truth: every key in + * VMCTL_KEYS, so a key in the table always works through uinput too. */ + for (int i = 0; i < VMCTL_KEYS_LEN; i++) + ioctl(fd, UI_SET_KEYBIT, VMCTL_KEYS[i].evdev); + } + if (role->want_buttons) { + for (int b = 0; b < 8; b++) + ioctl(fd, UI_SET_KEYBIT, (int)BTN_CODES[b]); + } - ioctl(fd, UI_SET_EVBIT, EV_REL); - ioctl(fd, UI_SET_RELBIT, REL_WHEEL); - ioctl(fd, UI_SET_RELBIT, REL_HWHEEL); - if (rel_motion) { + if (role->want_wheel || role->rel_motion) ioctl(fd, UI_SET_EVBIT, EV_REL); + if (role->want_wheel) { + ioctl(fd, UI_SET_RELBIT, REL_WHEEL); + ioctl(fd, UI_SET_RELBIT, REL_HWHEEL); + } + if (role->rel_motion) { ioctl(fd, UI_SET_RELBIT, REL_X); ioctl(fd, UI_SET_RELBIT, REL_Y); } - if (!rel_motion) { + if (!role->rel_motion) { ioctl(fd, UI_SET_EVBIT, EV_ABS); ioctl(fd, UI_SET_ABSBIT, ABS_X); ioctl(fd, UI_SET_ABSBIT, ABS_Y); @@ -145,6 +163,13 @@ static int uinput_driver_send(vmctl_t* v, const vmctl_batch* b) { int fd_a = v->ui_fd_a; int fd_b = v->ui_fd_b; int both = (fd_b >= 0); + /* Relative motion, mouse buttons and the scroll wheel all ride ONE carrier device — the + * relative-pointer device. Selected once from the same declarative layout used at create + * time (btn_on_b == carrier is B), so the hot path and the advertised capabilities agree. */ + uinput_role ra, rb; int btn_on_b = 0; + vmctl_uinput_layout(v->ptr_mode, &ra, &rb, &btn_on_b); + int fd_rel = btn_on_b ? fd_b : fd_a; + int fd_btn = fd_rel; for (int i = 0; i < b->count; i++) { int code = b->ev[i].code; @@ -159,23 +184,22 @@ static int uinput_driver_send(vmctl_t* v, const vmctl_batch* b) { break; case VMCTL_EV_REL: { if (!both && v->ptr_mode == VMCTL_PTR_ABS) return -1; - int fd = both ? fd_b : fd_a; - emit(fd, EV_REL, code == VMCTL_AXIS_X ? REL_X : REL_Y, value); - syn(fd); + emit(fd_rel, EV_REL, code == VMCTL_AXIS_X ? REL_X : REL_Y, value); + syn(fd_rel); break; } case VMCTL_EV_BTN: if (code < 0 || code >= 8) return -1; - emit(fd_a, EV_KEY, BTN_CODES[code], value); - syn(fd_a); + emit(fd_btn, EV_KEY, BTN_CODES[code], value); + syn(fd_btn); break; case VMCTL_EV_KEY: emit(fd_a, EV_KEY, (uint16_t)code, value); syn(fd_a); break; case VMCTL_EV_SCROLL: - emit(fd_a, EV_REL, code == VMCTL_SCROLL_V ? REL_WHEEL : REL_HWHEEL, (int32_t)scl); - syn(fd_a); + emit(fd_btn, EV_REL, code == VMCTL_SCROLL_V ? REL_WHEEL : REL_HWHEEL, (int32_t)scl); + syn(fd_btn); break; default: return -1; @@ -227,18 +251,22 @@ vmctl_t* vmctl_open_uinput_driver(const vmctl_config* cfg) { } char evdev_a[64], evdev_b[64]; - int rel_a = (cfg->ptr_mode == VMCTL_PTR_REL); - v->ui_fd_a = uinput_create(rel_a, id, dev_a, evdev_a); - if (v->ui_fd_a < 0) { free(v); return NULL; } + uinput_role role_a, role_b; + vmctl_uinput_layout(cfg->ptr_mode, &role_a, &role_b, NULL); /* declarative A/B split */ - if (cfg->ptr_mode == VMCTL_PTR_BOTH) { - v->ui_fd_b = uinput_create(1, id, dev_b, evdev_b); + v->ui_fd_a = uinput_create(&role_a, id, dev_a, evdev_a); + if (v->ui_fd_a < 0) { free(v); return NULL; } + memcpy(v->ui_evdev_a, evdev_a, sizeof v->ui_evdev_a); + + if (role_b.present) { + v->ui_fd_b = uinput_create(&role_b, id, dev_b, evdev_b); if (v->ui_fd_b < 0) { ioctl(v->ui_fd_a, UI_DEV_DESTROY); close(v->ui_fd_a); free(v); return NULL; } + memcpy(v->ui_evdev_b, evdev_b, sizeof v->ui_evdev_b); } if (cfg->qmp_path) { diff --git a/src/si/input/open.c b/src/si/input/open.c index 3a9e109..1ba8af3 100644 --- a/src/si/input/open.c +++ b/src/si/input/open.c @@ -154,3 +154,13 @@ unsigned vmctl_btns_snapshot(vmctl_t* v) { if (!v) return 0; return v->btns_held; } + +/* ===== uinput evdev export (UINPUT-only) ===== */ + +int vmctl_uinput_evdev(vmctl_t* v, char a[64], char b[64]) { + if (!v || v->driver != VMCTL_DRIVER_UINPUT) return -1; + int n = 0; + if (a) { memcpy(a, v->ui_evdev_a, sizeof v->ui_evdev_a); if (a[0]) n++; } + if (b) { memcpy(b, v->ui_evdev_b, sizeof v->ui_evdev_b); if (b[0]) n++; } + return n; +} diff --git a/src/test/test_uinputlayout.c b/src/test/test_uinputlayout.c new file mode 100644 index 0000000..6e006e5 --- /dev/null +++ b/src/test/test_uinputlayout.c @@ -0,0 +1,61 @@ +/* test_uinputlayout.c — DECLARATIVE uinput capability split (pure, no /dev/uinput). + * + * Verifies the ptr_mode -> A/B role mapping that drives both device creation and the hot-path + * button/wheel carrier selection: in PTR_BOTH A is keyboard+abs and B is rel+buttons+wheel, and + * the button/wheel carrier is B; single-pointer modes keep buttons+wheel on the sole device. + * The actuation ioctls remain armed-only (they need a real /dev/uinput); this covers the logic + * that decides the layout, which is the part that single-mode regressions would break. */ +#include "vmctl.h" +#include "uinput_layout.h" +#include + +static int g_fail = 0; +#define CHECK(cond, msg) do { \ + if (!(cond)) { printf(" FAIL: %s\n", (msg)); g_fail = 1; } \ +} while (0) + +int main(void) { + uinput_role a, b; int btn_on_b; + + /* PTR_BOTH: A = keyboard + absolute pointer, no buttons/wheel; B = relative pointer + + * buttons + wheel; carrier is B. This is the requested layout (mouse buttons incl. middle + * and the wheel moved off A onto B). */ + vmctl_uinput_layout(VMCTL_PTR_BOTH, &a, &b, &btn_on_b); + CHECK(a.present && b.present, "BOTH: two devices"); + CHECK(a.want_keyboard, "BOTH: A has keyboard"); + CHECK(!a.rel_motion, "BOTH: A is absolute"); + CHECK(!a.want_buttons, "BOTH: A has NO mouse buttons"); + CHECK(!a.want_wheel, "BOTH: A has NO wheel"); + CHECK(!b.want_keyboard, "BOTH: B has no keyboard"); + CHECK(b.rel_motion, "BOTH: B is relative"); + CHECK(b.want_buttons, "BOTH: B has mouse buttons"); + CHECK(b.want_wheel, "BOTH: B has wheel"); + CHECK(btn_on_b == 1, "BOTH: button/wheel carrier is B"); + + /* PTR_REL: single relative device A carries motion + buttons + wheel (no B). */ + vmctl_uinput_layout(VMCTL_PTR_REL, &a, &b, &btn_on_b); + CHECK(a.present && !b.present, "REL: single device A"); + CHECK(a.rel_motion, "REL: A is relative"); + CHECK(a.want_buttons, "REL: A has buttons"); + CHECK(a.want_wheel, "REL: A has wheel"); + CHECK(a.want_keyboard, "REL: A has keyboard"); + CHECK(btn_on_b == 0, "REL: carrier is A"); + + /* PTR_ABS: single absolute device A carries abs + buttons + wheel (the only device). */ + vmctl_uinput_layout(VMCTL_PTR_ABS, &a, &b, &btn_on_b); + CHECK(a.present && !b.present, "ABS: single device A"); + CHECK(!a.rel_motion, "ABS: A is absolute"); + CHECK(a.want_buttons, "ABS: A has buttons (sole device)"); + CHECK(a.want_wheel, "ABS: A has wheel (sole device)"); + CHECK(btn_on_b == 0, "ABS: carrier is A"); + + /* evdev export contract: a NULL handle reports "not a uinput handle" (-1). The populated + * path (real /dev/input/eventN) is armed-only — it needs a created uinput device. */ + { + char ea[64], eb[64]; + CHECK(vmctl_uinput_evdev(NULL, ea, eb) == -1, "evdev export: NULL handle -> -1"); + } + + printf("uinputlayout tests: %s\n", g_fail ? "FAIL" : "PASS"); + return g_fail ? 1 : 0; +} diff --git a/src/test/test_vmhost.c b/src/test/test_vmhost.c index 0a3490d..a644fcc 100644 --- a/src/test/test_vmhost.c +++ b/src/test/test_vmhost.c @@ -1,7 +1,12 @@ /* test_vmhost.c — QEMU/QMP host-plane, armed path: fake QMP server (this test) * <-> real QMP client vmhost. We verify: handshake (greeting -> qmp_capabilities * -> return -> SEAM_UP), async events -> VM_LIFECYCLE (broadcast), CMD_VM{QUERY} - * -> command to server -> return -> addressed VM_LIFECYCLE to the initiator, EOF -> SEAM_DOWN. */ + * -> command to server -> return -> addressed VM_LIFECYCLE to the initiator, EOF -> SEAM_DOWN. + * + * It also verifies the host->guest input bridge: with bridge_evdev_a/b set in cfg, on reaching + * READY the seam adds two input-linux objects (A with grab_all, B without) over its own + * connection, with neutral per-endpoint ids and the evdev paths from cfg; the bridge replies + * never surface as ACK/VM_LIFECYCLE to control; on teardown it fires object_del for both. */ #define _GNU_SOURCE #include "vmsig.h" #include "vmhost.h" /* private cfg (CMake provides the include path) */ @@ -25,6 +30,7 @@ static int g_fail = 0; static atomic_int g_seamup = 0, g_seamdown = 0; static atomic_int g_paused = 0, g_running_bcast = 0, g_query_reply = 0; +static atomic_int g_stray_ack = 0; /* any ACT_ACK reaching control would be a bridge leak */ static void* g_ctl = NULL; static int on_ev(void* user, const vmsig_event* ev) { @@ -38,6 +44,8 @@ static int on_ev(void* user, const vmsig_event* ev) { vmsig_inproc_send(g_ctl, &d); } else if (ev->kind == VMSIG_EV_SEAM_DOWN && ev->source == VMSIG_SRC_VMHOST) { atomic_store(&g_seamdown, 1); + } else if (ev->kind == VMSIG_EV_ACT_ACK && ev->source == VMSIG_SRC_VMHOST) { + atomic_fetch_add(&g_stray_ack, 1); /* bridge ops must NOT ack control */ } else if (ev->kind == VMSIG_EV_VM_LIFECYCLE) { vmsig_vm_state vs; memcpy(&vs, ev->inln, sizeof vs); if (ev->origin) { /* addressed reply to our QUERY */ @@ -64,16 +72,27 @@ static int srv_listen(const char* name) { return fd; } static void srv_send(int fd, const char* s) { ssize_t r = write(fd, s, strlen(s)); (void)r; } + +/* Persistent accumulator: client traffic can interleave (e.g. bridge object_add on READY vs. + * CMD_VM query on SEAM_UP), so we keep ALL received bytes and match substrings against the + * cumulative text instead of per-call buffers. */ +static char g_rx[8192]; +static size_t g_rxlen = 0; +static void rx_reset(void) { g_rxlen = 0; g_rx[0] = 0; } +static void rx_pump(int fd) { + ssize_t r = read(fd, g_rx + g_rxlen, sizeof g_rx - 1 - g_rxlen); + if (r > 0) { g_rxlen += (size_t)r; g_rx[g_rxlen] = 0; } +} +/* Wait until the cumulative client traffic contains needle (or timeout ~2s). */ static int srv_expect(int fd, const char* needle) { - char buf[1024]; size_t len = 0; - for (int i = 0; i < 200; i++) { /* up to ~2s */ - ssize_t r = read(fd, buf + len, sizeof buf - 1 - len); - if (r > 0) { len += (size_t)r; buf[len] = 0; if (strstr(buf, needle)) return 1; } - else if (r == 0) return 0; - else { struct timespec t = { 0, 10 * 1000000 }; nanosleep(&t, NULL); } - if (len >= sizeof buf - 1) len = 0; + for (int i = 0; i < 200; i++) { + if (strstr(g_rx, needle)) return 1; + rx_pump(fd); + if (strstr(g_rx, needle)) return 1; + struct timespec t = { 0, 10 * 1000000 }; nanosleep(&t, NULL); + if (g_rxlen >= sizeof g_rx - 1) return strstr(g_rx, needle) != NULL; /* full: stop growing */ } - return 0; + return strstr(g_rx, needle) != NULL; } static void wait_atomic(atomic_int* a, int ms) { for (int i = 0; i < ms; i++) { @@ -98,10 +117,16 @@ int main(void) { g.cap_mask = VMSIG_CAP_OBSERVE | VMSIG_CAP_VM; vmsig_core_add_control(core, vmsig_inproc_control_ops(), ctl, &g); - /* armed vmhost: it will connect to our fake QMP */ - vmsig_vmhost_cfg vcfg; memset(&vcfg, 0, sizeof vcfg); vcfg.qmp_path = QMP; + /* armed vmhost: it will connect to our fake QMP. Bridge evdev paths set => on READY the + * seam should add two input-linux objects forwarding them into the guest. */ + const char* EVDEV_A = "/dev/input/event42"; + const char* EVDEV_B = "/dev/input/event43"; + vmsig_vmhost_cfg vcfg; memset(&vcfg, 0, sizeof vcfg); + vcfg.qmp_path = QMP; vcfg.bridge_evdev_a = EVDEV_A; vcfg.bridge_evdev_b = EVDEV_B; CHECK(vmsig_core_add_adapter(core, vmsig_vmhost_ops(), &vcfg, 0) >= 0, "vmhost armed attach"); + rx_reset(); + pthread_t th; pthread_create(&th, NULL, loop_main, core); /* === QMP server role === */ @@ -113,17 +138,41 @@ int main(void) { srv_send(c, "{\"QMP\": {\"version\": {}, \"capabilities\": []}}\r\n"); CHECK(srv_expect(c, "qmp_capabilities"), "client sent qmp_capabilities"); - srv_send(c, "{\"return\": {}}\r\n"); /* -> READY -> SEAM_UP */ + srv_send(c, "{\"return\": {}}\r\n"); /* -> READY -> SEAM_UP + bridge */ + + /* On READY the seam adds the input-linux bridge BEFORE the SEAM_UP-driven CMD_VM query + * (bridge ids 1,2; query id 3). Verify both object_add lines and their properties. */ + CHECK(srv_expect(c, "object_add"), "seam sent object_add (input-linux bridge)"); + CHECK(srv_expect(c, "\"input-linux\""), "bridge object uses qom-type input-linux"); + CHECK(srv_expect(c, "\"vmsig-in-a-0\""), "bridge A has neutral per-endpoint id"); + CHECK(srv_expect(c, "\"vmsig-in-b-0\""), "bridge B has neutral per-endpoint id"); + CHECK(srv_expect(c, EVDEV_A), "bridge A carries the cfg evdev path for A"); + CHECK(srv_expect(c, EVDEV_B), "bridge B carries the cfg evdev path for B"); + srv_send(c, "{\"return\": {}, \"id\": 1}\r\n"); /* ack bridge A (consumed silently) */ + srv_send(c, "{\"return\": {}, \"id\": 2}\r\n"); /* ack bridge B (consumed silently) */ + + /* grab_all must be on A only: it appears exactly once, and only A's add carries it. The + * accumulator holds A's line ending in grab_all before B's id; assert B's add has none + * by checking grab_all precedes "vmsig-in-b-0" in the stream and never reappears. */ + { + const char* g1 = strstr(g_rx, "grab_all"); + const char* g2 = g1 ? strstr(g1 + 1, "grab_all") : NULL; + const char* bid = strstr(g_rx, "vmsig-in-b-0"); + CHECK(g1 != NULL, "grab_all present (on A)"); + CHECK(g2 == NULL, "grab_all appears exactly once (A only)"); + CHECK(g1 && bid && g1 < bid, "grab_all belongs to A's add, not B's"); + } srv_send(c, "{\"event\": \"STOP\"}\r\n"); /* -> broadcast PAUSED */ CHECK(srv_expect(c, "query-status"), "client sent query-status (from CMD_VM)"); - srv_send(c, "{\"return\": {\"status\": \"running\"}, \"id\": 1}\r\n"); /* -> addressed reply */ + srv_send(c, "{\"return\": {\"status\": \"running\"}, \"id\": 3}\r\n"); /* -> addressed reply */ srv_send(c, "{\"event\": \"RESUME\"}\r\n"); /* -> broadcast RUNNING */ wait_atomic(&g_seamup, 1000); wait_atomic(&g_paused, 1000); wait_atomic(&g_query_reply, 1000); wait_atomic(&g_running_bcast, 1000); + CHECK(atomic_load(&g_stray_ack) == 0, "bridge ops did not leak ACK to control"); close(c); /* EOF -> SEAM_DOWN */ wait_atomic(&g_seamdown, 1000); @@ -139,6 +188,59 @@ int main(void) { pthread_join(th, NULL); vmsig_core_free(core); vmsig_ctx_free(ctx); + + /* === Scenario 2: object_del on a clean reap (connection still READY) === + * The EOF path above marks the seam DEAD, so its best-effort del is (correctly) skipped. + * Here we reach READY then free the core WITHOUT EOF: vh_close must fire object_del for + * both bridge ids over the still-open socket before closing its fd. */ + { + const char* QMP2 = "@vmsig-qmp-fake-test-2"; + int srv2 = srv_listen(QMP2); + CHECK(srv2 >= 0, "scenario2: srv_listen"); + if (srv2 >= 0) { + vmsig_ctx* ctx2 = vmsig_ctx_new(); + vmsig_core* core2 = vmsig_core_new(ctx2); + vmsig_vmhost_cfg vc2; memset(&vc2, 0, sizeof vc2); + vc2.qmp_path = QMP2; vc2.bridge_evdev_a = EVDEV_A; vc2.bridge_evdev_b = EVDEV_B; + CHECK(vmsig_core_add_adapter(core2, vmsig_vmhost_ops(), &vc2, 0) >= 0, + "scenario2: vmhost attach"); + rx_reset(); + pthread_t th2; pthread_create(&th2, NULL, loop_main, core2); + + int c2 = accept(srv2, NULL, NULL); + CHECK(c2 >= 0, "scenario2: accept"); + if (c2 >= 0) { + struct timeval tv2 = { 0, 50 * 1000 }; + setsockopt(c2, SOL_SOCKET, SO_RCVTIMEO, &tv2, sizeof tv2); + srv_send(c2, "{\"QMP\": {\"version\": {}, \"capabilities\": []}}\r\n"); + CHECK(srv_expect(c2, "qmp_capabilities"), "scenario2: qmp_capabilities"); + srv_send(c2, "{\"return\": {}}\r\n"); /* READY -> bridge add */ + CHECK(srv_expect(c2, "vmsig-in-b-0"), "scenario2: bridge added"); + srv_send(c2, "{\"return\": {}, \"id\": 1}\r\n"); + srv_send(c2, "{\"return\": {}, \"id\": 2}\r\n"); + + /* Clean reap WITHOUT EOF: stop the loop then free (vh_close fires del). */ + vmsig_core_stop(core2); + pthread_join(th2, NULL); + vmsig_core_free(core2); + + for (int i = 0; i < 50 && !strstr(g_rx, "object_del"); i++) { + rx_pump(c2); + struct timespec t = { 0, 10 * 1000000 }; nanosleep(&t, NULL); + } + const char* d = strstr(g_rx, "object_del"); + CHECK(d != NULL, "scenario2: teardown fired object_del"); + CHECK(strstr(g_rx, "vmsig-in-a-0"), "scenario2: object_del for bridge A"); + CHECK(strstr(g_rx, "vmsig-in-b-0"), "scenario2: object_del for bridge B"); + close(c2); + } else { + vmsig_core_stop(core2); pthread_join(th2, NULL); vmsig_core_free(core2); + } + vmsig_ctx_free(ctx2); + close(srv2); + } + } + close(srv); printf("vmhost tests: %s\n", g_fail ? "FAIL" : "PASS");