//! `uevent-relay` — que un accesorio enchufado DESPUÉS de arrancar aparezca. //! //! ══ EL DEFECTO, MEDIDO EN METAL EL 2026-10-15 ═════════════════════════════════════════════════ //! El operador, con el escritorio abierto: *«los accesorios no aparecen cuando se conectan después //! de iniciar»*. No es el compositor y no es el kernel. Es una pieza que falta, y la ausencia es //! silenciosa en las tres capas: //! //! · El kernel crea el nodo (`CONFIG_DEVTMPFS_MOUNT=y`) y emite su uevent al **grupo netlink 1**. //! · `libudev-zero` —el libudev de la imagen, el único que sintetiza `ID_INPUT_*` sin `udevd`— //! abre el monitor `"udev"` en el **grupo 0x4**, que es donde `udevd` reenvía lo ya procesado. //! · `libinput` pide exactamente ese monitor (`udev_monitor_new_from_netlink(udev, "udev")`). //! //! Sin `udevd`, **nadie manda nunca nada al grupo 0x4**: el monitor no recibe un solo evento en //! toda la sesión. Y no es un bug de libudev-zero, es su contrato, escrito en su README: //! //! «If you're developing your own device manager, you need to rebroadcast kernel uevents to the //! `0x4` netlink group of NETLINK_KOBJECT_UEVENT. This is required because libudev-zero can't //! simply listen to kernel uevents due to potential race conditions.» //! «Hotplugging support is fully optional. You can skip this step…» //! //! takana se salteó ese paso. El candidato natural era `mdev`, y se **retiró** del defconfig de //! busybox el 2026-09-22 con este argumento: «`CONFIG_DEVTMPFS_MOUNT=y` hace que el kernel cree y //! monte `/dev`». Es cierto para los NODOS y no cubre el REENVÍO — y ahí se perdió el hotplug sin //! que ninguna prueba lo notara, porque todo lo que está enchufado al arrancar funciona igual. //! //! ══ POR QUÉ UN REENVIADOR Y NO UN PARCHE DE UNA LÍNEA ═════════════════════════════════════════ //! Hacer que libudev-zero escuche el grupo 1 es una línea, y fue lo primero que pensé. El autor //! explica por qué no: **carrera**. El uevent del kernel puede llegar antes de que el nodo de `/dev` //! o los atributos de `/sys` estén listos, y entonces `libinput` abre un dispositivo a medio nacer y //! lo descarta — un hotplug que funciona nueve de cada diez veces es peor que ninguno, porque nadie //! lo puede diagnosticar. Este proceso **espera a que el dispositivo exista** antes de reenviarlo, //! que es lo que cierra esa carrera, y deja libudev-zero sin tocar (su hash no se mueve). //! //! ══ LO QUE NO HACE ════════════════════════════════════════════════════════════════════════════ //! No crea nodos (lo hace devtmpfs), no aplica reglas, no monta nada. Un pendrive va a APARECER //! como `/dev/sdb1` y que alguien lo monte es otra conversación: esto es la campana, no la mano. use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::process::ExitCode; /// La familia netlink de los uevents. No está en `libc` para todos los targets, y es ABI estable. const NETLINK_KOBJECT_UEVENT: libc::c_int = 15; /// Donde emite el KERNEL. Un proceso de usuario puede escucharlo, pero no puede escribir ahí. const GRUPO_KERNEL: u32 = 1; /// Donde escucha `libudev-zero` el monitor `"udev"` (`UDEV_MONITOR_NLGRP` en su fuente). const GRUPO_UDEV: u32 = 4; /// Un `recvbuf` chico PIERDE eventos en ráfaga —enchufar un hub USB son veinte de golpe— y lo que /// se pierde no se reintenta nunca. udev pide 128 MiB; 8 cubre cualquier ráfaga de un escritorio. const RECVBUF: libc::c_int = 8 * 1024 * 1024; /// Cuánto se espera a que el dispositivo exista antes de reenviar (la carrera del README). const ESPERA_MAX_MS: u64 = 300; const ESPERA_PASO_MS: u64 = 10; fn main() -> ExitCode { let verboso = std::env::var_os("UEVENT_RELAY_DEBUG").is_some(); // ⚠ Sin privilegio no se puede hacer multicast a un grupo netlink: el `sendmsg` sale con // EPERM. Se dice ACÁ y con la causa, porque el síntoma de no decirlo es «el hotplug no anda» // otra vez, con un proceso vivo que parece estar trabajando. if unsafe { libc::geteuid() } != 0 { eprintln!( "uevent-relay: hace falta ser root (o CAP_NET_ADMIN) para emitir al grupo netlink {GRUPO_UDEV}; \ sin eso los accesorios enchufados en caliente no aparecen" ); return ExitCode::from(1); } let rx = match abrir_socket(GRUPO_KERNEL) { Ok(s) => s, Err(e) => { eprintln!("uevent-relay: no pude escuchar los uevents del kernel: {e}"); return ExitCode::from(1); } }; // El socket de salida se liga al grupo 0 —no escucha nada— sólo para que el kernel le asigne // un `nl_pid`. Emitir por el MISMO socket con el que se escucha haría que cada reenvío volviera // a entrar por el `recv`: un bucle que se multiplica solo. Medido en el diseño, no en producción. let tx = match abrir_socket(0) { Ok(s) => s, Err(e) => { eprintln!("uevent-relay: no pude abrir el socket de salida: {e}"); return ExitCode::from(1); } }; println!( "uevent-relay: escuchando el grupo {GRUPO_KERNEL} (kernel) y reenviando al {GRUPO_UDEV} (el que mira libudev-zero)" ); let mut buf = vec![0u8; 16 * 1024]; let mut reenviados: u64 = 0; loop { let n = unsafe { libc::recv(rx.as_raw_fd(), buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) }; if n <= 0 { // EINTR no es un error: una señal no tiene por qué matar el hotplug de la máquina. let err = std::io::Error::last_os_error(); if err.kind() == std::io::ErrorKind::Interrupted { continue; } eprintln!("uevent-relay: recv falló ({err}); salgo para que arje me reinicie"); return ExitCode::from(1); } let n = n as usize; let ev = Uevent::parsear(&buf[..n]); // `libudev-zero` descarta todo mensaje cuyo contenido no traiga las cuatro claves que // espera; reenviar lo que no es un uevent sería ruido en el socket de todos. if !ev.util() { continue; } if ev.accion.as_deref() == Some("add") { esperar_a_que_exista(&ev, verboso); } let mut sa: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; sa.nl_family = libc::AF_NETLINK as u16; sa.nl_groups = GRUPO_UDEV; let enviados = unsafe { libc::sendto( tx.as_raw_fd(), buf.as_ptr() as *const libc::c_void, n, 0, &sa as *const libc::sockaddr_nl as *const libc::sockaddr, std::mem::size_of::() as libc::socklen_t, ) }; if enviados < 0 { eprintln!( "uevent-relay: no pude reenviar el evento de {}: {}", ev.devpath.as_deref().unwrap_or("?"), std::io::Error::last_os_error() ); continue; } reenviados += 1; if verboso { eprintln!( "uevent-relay: #{reenviados} {} {} {}", ev.accion.as_deref().unwrap_or("?"), ev.subsistema.as_deref().unwrap_or("?"), ev.devpath.as_deref().unwrap_or("?") ); } } } fn abrir_socket(grupo: u32) -> std::io::Result { let fd = unsafe { libc::socket( libc::AF_NETLINK, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, NETLINK_KOBJECT_UEVENT, ) }; if fd < 0 { return Err(std::io::Error::last_os_error()); } let fd = unsafe { OwnedFd::from_raw_fd(fd) }; unsafe { libc::setsockopt( fd.as_raw_fd(), libc::SOL_SOCKET, libc::SO_RCVBUF, &RECVBUF as *const libc::c_int as *const libc::c_void, std::mem::size_of::() as libc::socklen_t, ); } let mut sa: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; sa.nl_family = libc::AF_NETLINK as u16; sa.nl_groups = grupo; let r = unsafe { libc::bind( fd.as_raw_fd(), &sa as *const libc::sockaddr_nl as *const libc::sockaddr, std::mem::size_of::() as libc::socklen_t, ) }; if r < 0 { return Err(std::io::Error::last_os_error()); } Ok(fd) } /// Lo que hace falta leer de un uevent para decidir si se reenvía y a qué hay que esperar. #[derive(Default, Debug, PartialEq)] struct Uevent { accion: Option, devpath: Option, subsistema: Option, devname: Option, seqnum: bool, } impl Uevent { /// El formato del alambre: `ACTION=add\0DEVPATH=/devices/...\0SUBSYSTEM=input\0...`, con un /// primer campo `add@/devices/...` que no es un par y se salta. fn parsear(buf: &[u8]) -> Uevent { let mut ev = Uevent::default(); for campo in buf.split(|&b| b == 0) { if campo.is_empty() { continue; } let Ok(s) = std::str::from_utf8(campo) else { continue }; let Some((k, v)) = s.split_once('=') else { continue }; match k { "ACTION" => ev.accion = Some(v.to_string()), "DEVPATH" => ev.devpath = Some(v.to_string()), "SUBSYSTEM" => ev.subsistema = Some(v.to_string()), "DEVNAME" => ev.devname = Some(v.to_string()), "SEQNUM" => ev.seqnum = true, _ => {} } } ev } /// Las cuatro claves que `libudev-zero` exige (`cnt != 4` ⇒ descarta el dispositivo). Reenviar /// un mensaje que el otro lado va a tirar es gastar el socket de todos. fn util(&self) -> bool { self.accion.is_some() && self.devpath.is_some() && self.subsistema.is_some() && self.seqnum } } /// La carrera que el README de libudev-zero nombra, cerrada: no se reenvía un `add` hasta que el /// dispositivo del que habla **existe**. Con devtmpfs el nodo suele estar antes que el evento, así /// que lo normal es que esto no espere ni un paso; cuando espera, es justo el caso que fallaba. fn esperar_a_que_exista(ev: &Uevent, verboso: bool) { let mut esperado = 0; while esperado < ESPERA_MAX_MS { let sys_ok = ev .devpath .as_ref() .map(|p| std::path::Path::new(&format!("/sys{p}")).exists()) .unwrap_or(true); let dev_ok = ev .devname .as_ref() .map(|n| std::path::Path::new(&format!("/dev/{n}")).exists()) .unwrap_or(true); if sys_ok && dev_ok { if esperado > 0 && verboso { eprintln!("uevent-relay: esperé {esperado} ms a que naciera {}", ev.devpath.as_deref().unwrap_or("?")); } return; } std::thread::sleep(std::time::Duration::from_millis(ESPERA_PASO_MS)); esperado += ESPERA_PASO_MS; } // No se descarta: un dispositivo que tarda más que esto igual puede servirle a libinput, y // tirarlo sería volver al síntoma. Se reenvía y se dice. if verboso { eprintln!( "uevent-relay: {} no apareció en {ESPERA_MAX_MS} ms; lo reenvío igual", ev.devpath.as_deref().unwrap_or("?") ); } } #[cfg(test)] mod tests { use super::*; /// Un uevent de verdad, copiado de la forma que emite el kernel al enchufar un ratón USB. fn crudo(partes: &[&str]) -> Vec { let mut v = Vec::new(); for p in partes { v.extend_from_slice(p.as_bytes()); v.push(0); } v } #[test] fn parsea_un_uevent_de_raton() { let buf = crudo(&[ "add@/devices/pci0000:00/0000:00:14.0/usb1/1-2/1-2:1.0/0003:046D:C077.0003/input/input21/event5", "ACTION=add", "DEVPATH=/devices/pci0000:00/0000:00:14.0/usb1/1-2/1-2:1.0/0003:046D:C077.0003/input/input21/event5", "SUBSYSTEM=input", "DEVNAME=input/event5", "SEQNUM=4821", ]); let ev = Uevent::parsear(&buf); assert_eq!(ev.accion.as_deref(), Some("add")); assert_eq!(ev.subsistema.as_deref(), Some("input")); assert_eq!(ev.devname.as_deref(), Some("input/event5")); assert!(ev.devpath.unwrap().ends_with("event5")); assert!(ev.seqnum); } /// El brazo de control: lo que `libudev-zero` va a tirar no se reenvía. Sin esta prueba, el /// relay podría estar «funcionando» y reenviando sólo basura. #[test] fn lo_que_el_otro_lado_descarta_no_se_reenvia() { // Sin SEQNUM: libudev-zero cuenta 3 de 4 claves y devuelve NULL. let sin_seq = Uevent::parsear(&crudo(&["ACTION=add", "DEVPATH=/devices/x", "SUBSYSTEM=input"])); assert!(!sin_seq.util(), "sin SEQNUM el otro lado lo tira"); // Sin SUBSYSTEM, idem. let sin_sub = Uevent::parsear(&crudo(&["ACTION=add", "DEVPATH=/devices/x", "SEQNUM=1"])); assert!(!sin_sub.util()); // Un mensaje que no es un uevent (los hay en ese socket) no pasa. assert!(!Uevent::parsear(b"basura sin pares").util()); // Y el completo sí. let ok = Uevent::parsear(&crudo(&[ "ACTION=remove", "DEVPATH=/devices/x", "SUBSYSTEM=usb", "SEQNUM=9", ])); assert!(ok.util()); } /// Un `remove` no espera a que nada exista —el dispositivo justamente ya no está— y ésa es la /// razón de que la espera cuelgue sólo del `add`. #[test] fn el_remove_no_espera() { let ev = Uevent::parsear(&crudo(&[ "ACTION=remove", "DEVPATH=/devices/no-existe-jamas", "SUBSYSTEM=input", "DEVNAME=input/event99", "SEQNUM=12", ])); assert_eq!(ev.accion.as_deref(), Some("remove")); let t0 = std::time::Instant::now(); // La función se llama sólo para `add` en el bucle; acá se comprueba que si se llamara con // un syspath inexistente, el techo es el declarado y no un cuelgue. esperar_a_que_exista(&ev, false); assert!( t0.elapsed().as_millis() <= (ESPERA_MAX_MS as u128) + 200, "la espera no tiene techo: {} ms", t0.elapsed().as_millis() ); } }