Started to implement poll

This commit is contained in:
Johannes Leupolz 2026-04-02 22:18:41 +00:00
parent 6f60635ff2
commit 8d4a0c9413
13 changed files with 343 additions and 7 deletions

View file

@ -37,7 +37,7 @@ jobs:
fuse3 \
libclang-dev
# debian packages, if packages are not downloaded via cargo
sudo apt install -y librust-bindgen-dev librust-nix-dev librust-libc-dev librust-time-dev librust-log-dev librust-env-logger-dev librust-libudev-dev librust-regex-dev librust-async-channel-dev librust-futures-dev librust-async-io-dev librust-anyhow-dev librust-clap-dev librust-base64-dev
sudo apt install -y librust-bindgen-dev librust-nix-dev librust-libc-dev librust-time-dev librust-log-dev librust-env-logger-dev librust-libudev-dev librust-regex-dev librust-async-channel-dev librust-futures-dev librust-async-io-dev librust-anyhow-dev librust-clap-dev librust-base64-dev librust-smallvec-dev
- name: Show versions (debug)
run: |

1
debian/control vendored
View file

@ -26,6 +26,7 @@ Build-Depends: debhelper (>= 12),
librust-anyhow-dev,
librust-clap-dev,
librust-base64-dev,
librust-smallvec-dev,
pkg-config,
build-essential
Standards-Version: 4.6.0

View file

@ -397,6 +397,14 @@ When mapping 32-bit compat input_event formats into 64-bit representation, copy
No high volume of events expected where we could benefit from multiple threads. But much of the code is already prepared for multithreading, if there is really demand.
**Poll / event readiness handling**
- For operations that wait on host device readiness (e.g., force feedback, rumble, vibration, or reading back event state), the CUSE callback must **never block**.
- A **poll/wakeup watcher** in a background thread monitors underlying `/dev/uinput` FDs and updates per-handle readiness (`PollState`) in `VuInputState` (see section 3.13).
- FUSE poll callbacks may save the provided poll handle and immediately return; the background watcher later invokes `fuse_notify_poll()` to wake the kernel when data arrives.
*Why:* this separates the fast data-plane (CUSE callbacks) from the asynchronous event-plane (poll watcher) and prevents hanging the filesystem.
---
## 3.9 Overriding the type, vendor id, and product id
@ -621,6 +629,80 @@ The filtering operates on two levels (Defense in Depth):
* **`strict-gamepad` (Whitelist):** Designed for console-like isolation. It strictly permits only Gamepad/Joystick events (`EV_KEY` buttons, `EV_ABS` axes). It proactively blocks `EV_REL` (mouse movement) and `ABS_MT` (multitouch), effectively "neutering" complex controllers (like DualSense or Wiimotes) so they cannot be used to hijack the host mouse cursor.
* **`sanitized` (Blacklist):** Designed for desktop gaming. It allows standard Keyboard and Mouse input but strictly filters dangerous keys (`KEY_SYSRQ`, `KEY_POWER`) and host-management shortcuts (VT switching, CAD), providing a safe "sandboxed keyboard."
---
## 3.13 Polling & Readiness Watcher
**Purpose**
- Provide non-blocking detection of device readiness on `/dev/vuinput` for operations like force feedback / rumble / vibration.
- Ensure CUSE callbacks (`poll`, `read`) never block and the FUSE filesystem remains responsive.
**Core design**
- Each `VuInputState` includes a `PollState` struct:
```rust
#[derive(Debug, Default)]
pub struct PollState {
/// A FUSE poll request is currently waiting to be woken.
pub waiting: bool,
/// Sticky readiness latch: true once evdev became readable, false after read/drain.
pub readable: bool,
}
```
* A **single background thread** watches all active uinput device file descriptors using **epoll** (or poll).
* When data arrives:
* The watcher locks the corresponding `VuInputState` via `VUINPUT_STATE`.
* Marks `poll.readable = true`.
* If `poll.waiting = true`, the watcher clears the flag and optionally calls `fuse_notify_poll()` to wake any waiting FUSE poll requests.
**Adding / removing devices**
* When creating a new uinput device, the thread performs `epoll_ctl(ADD)` for the file descriptor.
* On device close, it performs `epoll_ctl(DEL)` to remove the descriptor from monitoring.
* No separate registry is maintained; the **global `VUINPUT_STATE` HashMap** is the single source of truth.
**Poll callback behavior**
* FUSE poll callbacks **do not block**: they may store the poll handle and immediately return.
* The background watcher ensures that any pending poll handles are notified asynchronously when data is ready.
**Read handling**
* Reads from `/dev/vuinput` are non-blocking:
* `poll()` detects readiness.
* `read()` uses `O_NONBLOCK` and drains all available events.
* `EAGAIN` indicates the buffer is empty, at which point `poll.readable` is reset to false.
**Threading / shutdown**
* The background watcher uses a 500ms epoll_wait timeout to allow clean shutdown.
* It is safe to have a single watcher thread for all devices; epoll scales efficiently with multiple descriptors.
**Benefits**
* Fully non-blocking CUSE front-end.
* Lightweight: epoll manages file descriptors; no extra registry is needed.
* Correctly wakes FUSE poll requests for force feedback / rumble operations.
* Consistent with existing dispatcher rules: data-plane operations remain fast; control-plane updates scheduled as jobs if needed.
Poll/read race rule:
`PollState` is the single source of truth for readiness and pending poll waiters.
Both `poll()` and `read()` must update it only while holding the per-handle `VuInputState` mutex.
- `poll()` must first check `readable`; only if false may it enqueue a poll handle.
- the watcher sets `readable = true` and atomically drains pending waiters for notification.
- `read()` may clear `readable` only after draining the proxied evdev fd until `EAGAIN` (or equivalent proof of emptiness).
This prevents lost wakeups and stale readiness in the proxy.
---
## 4. Security Considerations
`vuinputd` must currently run with **root privileges** to:
@ -645,6 +727,7 @@ While this design is necessary for mediation, it introduces potential attack sur
* [ ] Use **seccomp** or `systemd` sandboxing (`ProtectSystem`, `ProtectKernelTunables`, `RestrictNamespaces`, etc.).
* [ ] Eventually migrate to **Rust-native FUSE/Netlink** bindings to remove unsafe dependencies.
---
## 5. Background: How are input devices created by the kernel using uinput

View file

@ -11,6 +11,7 @@ use crate::scenarios::ScenarioArgs;
use crate::test_log::{LoggedInputEvent, TestLog};
use libc::{self, ff_effect, ff_replay, ff_trigger};
//TODO: poll and erase
pub struct FfXboxGamepad;
impl FfXboxGamepad {

View file

@ -30,3 +30,11 @@ clap = { version = "4", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
base64 = "0.22"
smallvec = "1.15.1"
[features]
requires-privileges = []
requires-rootless = []
requires-uinput = []
requires-bwrap = []
requires-podman = []

View file

@ -0,0 +1,121 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::{
fs::File,
os::fd::{AsFd, BorrowedFd},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex, OnceLock,
},
thread::{self, JoinHandle},
time::Duration,
};
use anyhow::Context;
use cuse_lowlevel::fuse_lowlevel;
use nix::sys::epoll::{Epoll, EpollCreateFlags, EpollEvent, EpollFlags};
use crate::cuse_device::state::{get_vuinput_state, VuFileHandle};
pub static EVDEV_WRITE_WATCHER: OnceLock<Mutex<EvdevWriteWatcher>> = OnceLock::new();
pub fn initialize_evdev_write_watcher() -> anyhow::Result<()> {
EVDEV_WRITE_WATCHER
.set(Mutex::new(EvdevWriteWatcher::new()?)) // Convert the error from Mutex<T> to a simple string
.map_err(|_| anyhow::anyhow!("cell already full"))
// Now .context() works because &str is compatible
.context("failed to initialize evdev write watcher")?;
Ok(())
}
#[derive(Debug)]
pub struct EvdevWriteWatcher {
epoll: Arc<Epoll>,
shutdown: Arc<AtomicBool>,
thread_handle: Option<JoinHandle<()>>,
}
impl EvdevWriteWatcher {
fn new() -> anyhow::Result<Self> {
let epoll = Arc::new(Epoll::new(EpollCreateFlags::empty())?);
let shutdown = Arc::new(AtomicBool::new(false));
let epoll_thread = epoll.clone();
let shutdown_thread = shutdown.clone();
let thread_handle = Some(thread::spawn(move || {
evdev_write_watch_loop(shutdown_thread, epoll_thread);
}));
Ok(Self {
thread_handle: thread_handle,
shutdown: shutdown,
epoll: epoll,
})
}
pub fn add_device(&self, vu_fh: VuFileHandle) -> nix::Result<()> {
let VuFileHandle::Fh(fh) = vu_fh;
let vuinput_state_mutex = get_vuinput_state(&vu_fh).unwrap();
let vuinput_state = vuinput_state_mutex.lock().unwrap();
self.epoll.add(
&vuinput_state.file,
EpollEvent::new(EpollFlags::EPOLLIN, fh),
)
}
pub fn remove_device<Fd: AsFd>(&self, uinput_fd: Fd) -> nix::Result<()> {
self.epoll.delete(uinput_fd)
}
pub fn stop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(handle) = self.thread_handle.take() {
let _ = handle.join();
}
}
pub fn is_running(&self) -> bool {
self.thread_handle.is_some()
}
}
fn evdev_write_watch_loop(shutdown: Arc<AtomicBool>, epoll: Arc<Epoll>) {
let mut events = vec![EpollEvent::empty(); 64];
loop {
if shutdown.load(Ordering::SeqCst) {
break;
}
let n = match epoll.wait(&mut events, 500u16) {
Ok(n) => n,
Err(err) => {
eprintln!("evdev_write_watcher: epoll_wait failed: {err}");
thread::sleep(Duration::from_millis(100));
continue;
}
};
for ev in &events[..n] {
let fh_val = ev.data() as u64;
let fh = VuFileHandle::Fh(fh_val);
let state = super::state::get_vuinput_state(&fh);
if let Ok(state) = state {
let mut state = state.lock().unwrap();
for handle in state.poll.take_waiters() {
unsafe {
fuse_lowlevel::fuse_lowlevel_notify_poll(handle.as_ptr());
fuse_lowlevel::fuse_pollhandle_destroy(handle.as_ptr());
}
}
state.poll.readable = true;
state.poll.pending.clear();
}
}
}
}

View file

@ -3,9 +3,11 @@
// Author: Johannes Leupolz <dev@leupolz.eu>
pub mod device_policy;
pub mod evdev_write_watcher;
pub mod state;
pub mod vuinput_ioctl;
pub mod vuinput_open;
pub mod vuinput_poll;
pub mod vuinput_read;
pub mod vuinput_release;
pub mod vuinput_write;
@ -36,6 +38,7 @@ pub fn vuinput_make_cuse_ops() -> cuse_lowlevel::cuse_lowlevel_ops {
release: Some(vuinput_release::vuinput_release),
fsync: None,
ioctl: Some(vuinput_ioctl::vuinput_ioctl),
//poll: Some(vuinput_poll::vuinput_poll),
poll: None,
}
}

View file

@ -4,11 +4,16 @@
use std::collections::HashMap;
use std::fs::File;
use std::ptr::NonNull;
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use ::cuse_lowlevel::*;
use smallvec::SmallVec;
use crate::process_tools::RequestingProcess;
use smallvec::smallvec;
pub type PendingPollHandles = SmallVec<[*mut fuse_lowlevel::fuse_pollhandle; 1]>;
#[derive(Debug)]
pub struct VuInputDevice {
@ -38,12 +43,55 @@ impl KeyTracker {
}
}
/// this data structure ensures poll and read are synchronized.
/// poll() and read() must synchronize through one shared readines
/// state, and the state transitions must be done under the same per-handle mutex.
/// Ensure, we have no lost-wakeup races like:
/// 1) watcher sets readable
/// 2) read() drains and clears readable
/// 3) poll() stores waiter too late
/// 4) nobody wakes it anymore
#[derive(Debug, Default)]
pub struct PollState {
/// Sticky readiness latch:
/// true once evdev became readable, false again after read/drain.
pub readable: bool,
/// Pending FUSE poll waiters for this device.
/// Optimized for the common case of 0 or 1 waiter, but supports
/// multiple concurrent poll() callers correctly.
pub pending: SmallVec<[NonNull<fuse_lowlevel::fuse_pollhandle>; 1]>,
}
impl PollState {
pub fn new() -> PollState {
PollState {
readable: false,
pending: smallvec![],
}
}
pub fn has_waiters(&self) -> bool {
!self.pending.is_empty()
}
pub fn add_waiter(&mut self, handle: NonNull<fuse_lowlevel::fuse_pollhandle>) {
self.pending.push(handle);
}
pub fn take_waiters(&mut self) -> SmallVec<[NonNull<fuse_lowlevel::fuse_pollhandle>; 1]> {
std::mem::take(&mut self.pending)
}
}
unsafe impl Send for PollState {}
#[derive(Debug)]
pub struct VuInputState {
pub file: File,
pub requesting_process: RequestingProcess,
pub input_device: Option<VuInputDevice>,
pub keytracker: KeyTracker,
pub poll: PollState,
}
#[derive(Debug, Eq, Hash, PartialEq, Clone)]

View file

@ -5,13 +5,16 @@
use ::cuse_lowlevel::*;
use libc::ENOENT;
use libc::O_CLOEXEC;
use libc::O_NONBLOCK;
use log::{debug, error};
use std::fs::OpenOptions;
use std::os::fd::AsFd;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
use crate::cuse_device::evdev_write_watcher::EVDEV_WRITE_WATCHER;
use crate::cuse_device::*;
use crate::process_tools::{get_requesting_process, Pid};
@ -43,21 +46,31 @@ pub unsafe extern "C" fn vuinput_open(
let open_vuinput_result = OpenOptions::new()
.read(true)
.write(true)
//.custom_flags(O_NONBLOCK)
.custom_flags(O_NONBLOCK)
.custom_flags(O_CLOEXEC)
.open(Path::new("/dev/uinput"));
match open_vuinput_result {
Ok(v) => {
let vu_fh: VuFileHandle = VuFileHandle::Fh(fh);
let uinput_fd: std::os::unix::prelude::BorrowedFd<'_> = v.as_fd();
insert_vuinput_state(
&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap()),
&vu_fh,
VuInputState {
file: v,
requesting_process,
input_device: None,
keytracker: KeyTracker::new(),
poll: PollState::new(),
},
)
.unwrap();
EVDEV_WRITE_WATCHER
.get()
.unwrap()
.lock()
.unwrap()
.add_device(vu_fh)
.unwrap();
fuse_lowlevel::fuse_reply_open(_req, _fi);
}
Err(e) => {

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use crate::cuse_device::*;
use crate::global_config::get_device_policy;
use ::cuse_lowlevel::*;
use libc::{__s32, __u16, input_event};
use libc::{off_t, size_t, EIO};
use libc::{uinput_abs_setup, uinput_setup};
use log::{debug, trace};
use std::io::{Read, Write};
use std::os::fd::AsRawFd;
use std::os::raw::c_char;
use uinput_ioctls::*;
// https://github.com/libfuse/libfuse/blob/master/example/poll.c
pub unsafe extern "C" fn vuinput_poll(
req: fuse_lowlevel::fuse_req_t,
fi: *mut fuse_lowlevel::fuse_file_info,
ph: *mut fuse_lowlevel::fuse_pollhandle,
) {
/*
let vuinput_state_mutex =
get_vuinput_state(&VuFileHandle::from_fuse_file_info(fi.as_ref().unwrap())).unwrap();
let mut vuinput_state = vuinput_state_mutex.lock().unwrap();
if state.poll.readable {
// return POLLIN immediately
} else if let Some(handle) = NonNull::new(ph) {
state.poll.add_waiter(handle); */
}

View file

@ -32,16 +32,17 @@ pub unsafe extern "C" fn vuinput_read(
get_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap();
let mut vuinput_state = vuinput_state_mutex.lock().unwrap();
let normal_size = std::mem::size_of::<libc::input_event>();
const NORMAL_SIZE: usize = std::mem::size_of::<libc::input_event>();
let is_compat = vuinput_state.requesting_process.is_compat;
// TODO: ARM: && !compat_uses_64bit_time()
let mut buffer: [u8; 24] = [0; 24];
// read up to 24 bytes
// todo: non-blocking or timeout
let result = vuinput_state.file.read(&mut buffer);
match result {
Ok(normal_size) => {
Ok(NORMAL_SIZE) => {
if (!is_compat) {
let buffer = buffer.as_ptr() as *const i8;
fuse_lowlevel::fuse_reply_buf(_req, buffer, 24);
@ -63,4 +64,10 @@ pub unsafe extern "C" fn vuinput_read(
fuse_lowlevel::fuse_reply_err(_req, EIO);
}
}
/*
if drained_to_eagain {
state.poll.readable = false;
} else {
state.poll.readable = true;
} */
}

View file

@ -2,12 +2,14 @@
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use crate::cuse_device::evdev_write_watcher::EVDEV_WRITE_WATCHER;
use crate::job_engine::JOB_DISPATCHER;
use crate::jobs::remove_device_job::RemoveDeviceJob;
use crate::process_tools::SELF_NAMESPACES;
use crate::{cuse_device::*, jobs};
use ::cuse_lowlevel::*;
use log::debug;
use std::os::fd::AsFd;
use std::sync::Arc;
pub unsafe extern "C" fn vuinput_release(
@ -15,8 +17,8 @@ pub unsafe extern "C" fn vuinput_release(
_fi: *mut fuse_lowlevel::fuse_file_info,
) {
let fh = &(*_fi).fh;
let vuinput_state_mutex =
remove_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap();
let vu_fh = VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap());
let vuinput_state_mutex = remove_vuinput_state(&vu_fh).unwrap();
let mut vuinput_state = vuinput_state_mutex.lock().unwrap();
let input_device = vuinput_state.input_device.take();
@ -49,6 +51,14 @@ pub unsafe extern "C" fn vuinput_release(
awaiter(&jobs::remove_device_job::State::Finished);
}
EVDEV_WRITE_WATCHER
.get()
.unwrap()
.lock()
.unwrap()
.remove_device(vuinput_state.file.as_fd())
.unwrap();
drop(vuinput_state);
debug!(

View file

@ -29,6 +29,9 @@ use std::sync::Mutex;
pub mod cuse_device;
use crate::cuse_device::evdev_write_watcher::{
initialize_evdev_write_watcher, EVDEV_WRITE_WATCHER,
};
use crate::cuse_device::state::{initialize_dedup_last_error, initialize_vuinput_state};
use crate::cuse_device::vuinput_make_cuse_ops;
use crate::cuse_device::vuinput_open::VUINPUT_COUNTER;
@ -194,6 +197,9 @@ fn main() -> std::io::Result<()> {
vt_tools::check_vt_status();
global_config::initialize_global_config(&args.device_policy, &args.placement, &args.devname);
initialize_evdev_write_watcher().expect(
"failed to initialize the watcher that watches for writes on the created evdev devices",
);
initialize_vuinput_state();
VUINPUT_COUNTER.set(AtomicU64::new(3)).expect(
"failed to initialize the counter that provides the values of the CUSE file handles",
@ -280,5 +286,7 @@ fn main() -> std::io::Result<()> {
.unwrap()
.wait_until_finished();
EVDEV_WRITE_WATCHER.get().unwrap().lock().unwrap().stop();
Ok(())
}