From 17d64caf3706bbad26dc01078be4b3b5b91f0408 Mon Sep 17 00:00:00 2001 From: "Florian S. Kluge" <57475226+0xFlo@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:43:50 +0100 Subject: [PATCH] fix(wlroots): share one virtual keyboard across clients (#491) * fix(wlroots): share one virtual keyboard across clients add_client sent a keymap fd per emulation client, and it is the same fd every time, so under client churn the queue grew past MAX_FDS_OUT and sendmsg failed with ETOOMANYREFS. One lazily created keyboard sends it once. The pointer stays per client because it carries no descriptor. * test(wlroots): add a descriptor churn reproduction Creates N emulation clients while sampling open descriptors, with a control mode that holds the client count at one to distinguish client creation from the event path. Motion is always zero valued so it never moves the cursor. --- input-emulation/examples/keymap_fd_churn.rs | 132 ++++++++++++++++++++ input-emulation/src/wlroots.rs | 25 ++-- 2 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 input-emulation/examples/keymap_fd_churn.rs diff --git a/input-emulation/examples/keymap_fd_churn.rs b/input-emulation/examples/keymap_fd_churn.rs new file mode 100644 index 0000000..4ea4e09 --- /dev/null +++ b/input-emulation/examples/keymap_fd_churn.rs @@ -0,0 +1,132 @@ +//! Reproduction for issue #478: "Too many references: cannot splice (os error 109)", +//! which is ETOOMANYREFS, on a wlroots compositor. +//! +//! `State::add_client` creates a virtual keyboard per emulation client and hands the +//! compositor a keymap file descriptor. It is the same descriptor every time, and +//! Wayland caps descriptors per sendmsg (MAX_FDS_OUT), so under client churn they +//! queue faster than the compositor drains them until the send is refused. +//! +//! Run under a wlroots compositor: +//! cargo run --release -p input-emulation --example keymap_fd_churn +//! cargo run --release -p input-emulation --example keymap_fd_churn -- --control +//! +//! Churn mode creates one client per iteration. Control mode holds the client count +//! at one and sends the same number of events, which is what distinguishes client +//! creation from the event path. +//! +//! The descriptor limit matters. `too_many_unix_fds()` compares the USER's total +//! in-flight SCM_RIGHTS count against the SENDER's RLIMIT_NOFILE, so a limit below +//! the machine's ambient in-flight count fails at iteration 0 regardless of this bug, +//! and a limit far above it never trips. On the machine this was written on, ambient +//! sits near 1024 and `ulimit -n 2048` fails at about iteration 900 before the fix and +//! runs clean after it. +//! +//! Motion events are sent with dx and dy of zero so the example never moves the real +//! cursor. + +use input_emulation::{Backend, EmulationHandle, InputEmulation}; +use input_event::{Event, PointerEvent}; + +/// number of simulated peer reconnects +const ITERATIONS: u64 = 2000; + +/// how often to sample the descriptor count +const SAMPLE_EVERY: u64 = 100; + +/// counts open descriptors of this process. +/// returns None rather than 0 when the count itself fails for lack of a descriptor, +/// so an exhausted process is never reported as using none. +fn open_fds() -> Option { + std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count()) +} + +fn fds_display(fds: Option) -> String { + match fds { + Some(n) => n.to_string(), + None => "unreadable (process out of descriptors)".to_string(), + } +} + +fn main() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build runtime"); + + runtime.block_on(async { + let mut emulation = match InputEmulation::new(Some(Backend::Wlroots)).await { + Ok(e) => e, + Err(e) => { + eprintln!("could not create wlroots emulation backend: {e}"); + eprintln!("this example requires a running wlroots compositor"); + std::process::exit(1); + } + }; + + // control mode reuses a single client, so only the event path runs. + // if descriptors stay flat here but climb in the default mode, client + // creation is the source and the event path is exonerated. + let control = std::env::args().any(|a| a == "--control"); + + let baseline = open_fds().unwrap_or(0); + println!( + "mode: {}", + if control { + "control (one client, N events)" + } else { + "churn (N clients)" + } + ); + println!("baseline open fds: {baseline}"); + println!("running {ITERATIONS} iterations"); + + if control { + emulation.create(0).await; + } + + for i in 0..ITERATIONS { + let handle = if control { 0 } else { i as EmulationHandle }; + + if !control { + // mirrors do_emulation_session: an unseen peer address creates a client + emulation.create(handle).await; + } + + // a zero motion event is harmless but forces a flush of the queued keymap fd + let event = Event::Pointer(PointerEvent::Motion { + time: 0, + dx: 0.0, + dy: 0.0, + }); + + if let Err(e) = emulation.consume(event, handle).await { + println!(); + println!("FAILED at iteration {i}"); + println!( + "open fds: {} (baseline {baseline})", + fds_display(open_fds()) + ); + println!("error: {e}"); + std::process::exit(1); + } + + if i % SAMPLE_EVERY == 0 { + let now = open_fds(); + let delta = now.map(|n| n.saturating_sub(baseline)); + println!( + " iteration {i:>5}: open fds {:>6}{}", + fds_display(now), + delta + .map(|d| format!(" (+{d} over baseline)")) + .unwrap_or_default(), + ); + } + } + + let final_fds = open_fds(); + println!(); + println!("completed {ITERATIONS} iterations without error"); + println!("open fds: {} (baseline {baseline})", fds_display(final_fds)); + emulation.terminate().await; + }); +} diff --git a/input-emulation/src/wlroots.rs b/input-emulation/src/wlroots.rs index f79f8d9..c010265 100644 --- a/input-emulation/src/wlroots.rs +++ b/input-emulation/src/wlroots.rs @@ -37,6 +37,8 @@ use super::error::WaylandBindError; struct State { keymap: Option<(u32, OwnedFd, u32)>, + /// shared by all clients, so the keymap fd is sent once instead of once per client + keyboard: Option, input_for_client: HashMap, seat: wl_seat::WlSeat, qh: QueueHandle, @@ -74,6 +76,7 @@ impl WlrootsEmulation { last_flush_failed: false, state: State { keymap: None, + keyboard: None, input_for_client, seat, vpm, @@ -95,14 +98,20 @@ impl WlrootsEmulation { impl State { fn add_client(&mut self, client: EmulationHandle) { let pointer: Vp = self.vpm.create_virtual_pointer(None, &self.qh, ()); - let keyboard: Vk = self.vkm.create_virtual_keyboard(&self.seat, &self.qh, ()); - // TODO: use server side keymap - if let Some((format, fd, size)) = self.keymap.as_ref() { - keyboard.keymap(*format, fd.as_fd(), *size); - } else { - panic!("no keymap"); - } + let keyboard = match self.keyboard.as_ref() { + Some(keyboard) => keyboard.clone(), + None => { + let keyboard: Vk = self.vkm.create_virtual_keyboard(&self.seat, &self.qh, ()); + // TODO: use server side keymap + let Some((format, fd, size)) = self.keymap.as_ref() else { + panic!("no keymap"); + }; + keyboard.keymap(*format, fd.as_fd(), *size); + self.keyboard = Some(keyboard.clone()); + keyboard + } + }; let vinput = VirtualInput { pointer, @@ -114,9 +123,9 @@ impl State { } fn destroy_client(&mut self, handle: EmulationHandle) { + // the shared keyboard outlives every client; keys are released by InputEmulation::destroy if let Some(input) = self.input_for_client.remove(&handle) { input.pointer.destroy(); - input.keyboard.destroy(); } } }