> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
feat(uevent-relay): que un accesorio enchufado DESPUES de arrancar aparezca
Reportado en metal: «los accesorios no aparecen cuando se conectan después de iniciar». No era el
compositor ni el kernel: faltaba una pieza, y las tres capas callan.
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 unico que sintetiza ID_INPUT_* sin udevd— abre el
monitor "udev" en el grupo 0x4, que es donde udevd reenvia lo ya procesado. Y libinput pide
exactamente ese monitor. Sin udevd NADIE EMITE NUNCA al 0x4: el monitor no recibe un solo evento en
toda la sesion.
No es un bug de libudev-zero, es su contrato, escrito en su README: «you need to rebroadcast kernel
uevents to the 0x4 netlink group [...] libudev-zero can't simply listen to kernel uevents due to
potential race conditions» y «Hotplugging support is fully optional. You can skip this step».
takana se salteo ese paso. El candidato natural era `mdev`, y se RETIRO del defconfig de busybox el
22-sep con el argumento «CONFIG_DEVTMPFS_MOUNT=y hace que el kernel cree y monte /dev» — cierto
para los NODOS, y no cubre el REENVIO. Ahi se perdio el hotplug sin que ninguna prueba lo notara,
porque todo lo que esta enchufado al arrancar funciona igual.
Un reenviador y no un parche de una linea a libudev-zero: hacer que escuche el grupo 1 es una
linea, y el autor explica por que no — CARRERA. El uevent puede llegar antes de que el nodo o los
atributos de /sys esten listos, y entonces libinput abre un dispositivo a medio nacer y lo
descarta; un hotplug que anda nueve de cada diez veces es peor que ninguno, porque nadie lo puede
diagnosticar. Este proceso ESPERA a que el dispositivo exista (techo de 300 ms, paso de 10) antes
de reenviar, y deja libudev-zero sin tocar: su hash no se mueve.
Solo libc, netlink a mano, igual que netup/hammerd. Dos sockets y no uno: emitir por el mismo con
el que se escucha haria que cada reenvio volviera a entrar por el recv.
Certificado: 3 tests del parseo (incluido el brazo de control de que NO se reenvia lo que
libudev-zero descarta por no traer sus cuatro claves) + scripts/probar-uevent-relay.py, que mide el
transporte EN VIVO y cuyo brazo de control es gratis: en una maquina con udev de verdad, udevd
emite al grupo 0x2 y nunca al 0x4, asi que todo lo que aparece en el 4 lo puso este relay. Las dos
mitades corridas en la laptop del operador: sin el relay el grupo 4 esta mudo; con el relay el
evento llega con ACTION/DEVPATH/SUBSYSTEM/SEQNUM y en la forma exacta del kernel.
Lo que NO hace: no crea nodos (devtmpfs), no aplica reglas, no monta nada. Un pendrive va a
APARECER como /dev/sdb1 y que alguien lo monte es otra conversacion: esto es la campana, no la
mano.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/Cargo.lock b/Cargo.lock
index 62118347..85685d0a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1555,6 +1555,13 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+[[package]]
+name = "uevent-relay"
+version = "0.0.1"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "ulid"
version = "1.2.1"
diff --git a/Cargo.toml b/Cargo.toml
index 846269d1..c27f1770 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,9 @@ members = [
"crates/netup",
"crates/simi",
"crates/akllay",
+ # El reenviador de uevents: sin él, `libudev-zero` abre su monitor en un grupo netlink al que
+ # nadie emite (no hay udevd) y un accesorio enchufado después de arrancar no aparece nunca.
+ "crates/uevent-relay",
]
[workspace.package]
diff --git a/crates/uevent-relay/Cargo.toml b/crates/uevent-relay/Cargo.toml
new file mode 100644
index 00000000..276351d0
--- /dev/null
+++ b/crates/uevent-relay/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "uevent-relay"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+description = "Reenvía los uevents del kernel al grupo netlink que escucha libudev-zero: sin esto, un accesorio enchufado después de arrancar no aparece."
+
+[[bin]]
+name = "uevent-relay"
+path = "src/main.rs"
+
+# Sólo libc, igual que netup/hammerd: netlink a mano sobre la ABI estable del kernel. Cero deps
+# nuevas que vendorar ⇒ build hermético y reproducible.
+[dependencies]
+libc = "0.2"
diff --git a/crates/uevent-relay/src/main.rs b/crates/uevent-relay/src/main.rs
new file mode 100644
index 00000000..62c7d1c0
--- /dev/null
+++ b/crates/uevent-relay/src/main.rs
@@ -0,0 +1,338 @@
+//! `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::<libc::sockaddr_nl>() 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<OwnedFd> {
+ 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::<libc::c_int>() 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::<libc::sockaddr_nl>() 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<String>,
+ devpath: Option<String>,
+ subsistema: Option<String>,
+ devname: Option<String>,
+ 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<u8> {
+ 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()
+ );
+ }
+}
diff --git a/scripts/probar-uevent-relay.py b/scripts/probar-uevent-relay.py
new file mode 100644
index 00000000..abd76059
--- /dev/null
+++ b/scripts/probar-uevent-relay.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""probar-uevent-relay.py — ¿un uevent del kernel LLEGA al grupo que mira libudev-zero?
+
+El síntoma que esto cubre es «los accesorios no aparecen cuando se conectan después de iniciar»
+(metal, 2026-10-15). La causa está en `crates/uevent-relay/src/main.rs`: libudev-zero abre su
+monitor en el grupo netlink 0x4 y sin `udevd` nadie emite ahí nunca.
+
+**El brazo de control es gratis y por eso es obligatorio:** en una máquina con udev de verdad,
+`udevd` reenvía al grupo 0x2 — nunca al 0x4. Así que todo lo que aparezca en el 0x4 lo puso este
+relay y nada más. La prueba corre las dos mitades:
+
+ 1. CON el relay vivo ⇒ el evento disparado a mano aparece en el grupo 4.
+ 2. SIN el relay ⇒ no aparece (si apareciera, la prueba no estaría midiendo el relay).
+
+No necesita hardware: el uevent se dispara escribiendo `change` en el `uevent` de un dispositivo
+que ya existe, que es exactamente el mensaje que el kernel emite al enchufar uno nuevo.
+
+ sudo python3 scripts/probar-uevent-relay.py [ruta-al-binario]
+"""
+import os
+import socket
+import subprocess
+import sys
+import time
+
+NETLINK_KOBJECT_UEVENT = 15
+GRUPO_UDEV = 4 # el que mira libudev-zero
+ESPERA = 4.0
+
+BIN = sys.argv[1] if len(sys.argv) > 1 else "target/debug/uevent-relay"
+
+
+def fallar(msg):
+ print(f"✗ FALLO: {msg}")
+ sys.exit(1)
+
+
+def escucha_grupo_4():
+ s = socket.socket(socket.AF_NETLINK, socket.SOCK_DGRAM, NETLINK_KOBJECT_UEVENT)
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20)
+ s.bind((0, GRUPO_UDEV))
+ s.settimeout(ESPERA)
+ return s
+
+
+def un_dispositivo_cualquiera():
+ """Alguno que exista y tenga `uevent` escribible. Un input es lo más parecido al caso real."""
+ for base in ("/sys/class/input", "/sys/class/net", "/sys/block"):
+ if not os.path.isdir(base):
+ continue
+ for n in sorted(os.listdir(base)):
+ p = os.path.join(base, n, "uevent")
+ if os.access(p, os.W_OK):
+ return p
+ return None
+
+
+def disparar(uevent_path):
+ with open(uevent_path, "w") as f:
+ f.write("change")
+
+
+def recibir(s):
+ try:
+ return s.recv(16384)
+ except socket.timeout:
+ return None
+
+
+if os.geteuid() != 0:
+ print("este test necesita root (bind a un grupo netlink y escribir en /sys)")
+ sys.exit(2)
+if not os.path.exists(BIN):
+ print(f"no encuentro {BIN} — cargo build -p uevent-relay")
+ sys.exit(2)
+
+uevent = un_dispositivo_cualquiera()
+if not uevent:
+ print("no encontré ningún dispositivo con `uevent` escribible: no se puede medir")
+ sys.exit(2)
+print(f"== disparando sobre {uevent} ==")
+
+# ── 1. EL BRAZO DE CONTROL, PRIMERO: sin el relay, el grupo 4 está mudo ───────────────────────
+# Va primero a propósito: si se corriera después, un evento rezagado del relay anterior podría
+# colarse y el control pasaría por casualidad.
+s = escucha_grupo_4()
+disparar(uevent)
+sobra = recibir(s)
+s.close()
+if sobra is not None:
+ fallar(
+ "SIN el relay ya llega algo al grupo 4: esta prueba no está midiendo el relay\n"
+ f" recibido: {sobra[:120]!r}"
+ )
+print(" ✓ control: sin el relay, el grupo 4 no recibe nada (udevd emite al 2, no al 4)")
+
+# ── 2. CON el relay: el mismo evento aparece ──────────────────────────────────────────────────
+s = escucha_grupo_4()
+env = dict(os.environ, UEVENT_RELAY_DEBUG="1")
+relay = subprocess.Popen([BIN], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+time.sleep(0.6) # que ligue su socket antes de disparar
+if relay.poll() is not None:
+ fallar(f"el relay murió al arrancar:\n{relay.stderr.read()}")
+
+disparar(uevent)
+got = recibir(s)
+s.close()
+relay.terminate()
+try:
+ _, err = relay.communicate(timeout=3)
+except subprocess.TimeoutExpired:
+ relay.kill()
+ _, err = relay.communicate()
+
+if got is None:
+ fallar(f"el evento NO llegó al grupo 4 en {ESPERA}s\n stderr del relay: {err[:400]}")
+
+texto = got.decode("utf-8", "replace")
+campos = dict(
+ p.split("=", 1) for p in texto.split("\0") if "=" in p
+)
+print(f" ✓ llegó al grupo 4: {len(got)} bytes")
+for clave in ("ACTION", "DEVPATH", "SUBSYSTEM", "SEQNUM"):
+ if clave not in campos:
+ fallar(f"el evento reenviado no trae {clave} — libudev-zero lo descartaría (exige las cuatro)")
+print(f" ✓ trae las cuatro claves que libudev-zero exige: "
+ f"ACTION={campos['ACTION']} SUBSYSTEM={campos['SUBSYSTEM']} DEVPATH={campos['DEVPATH'][:48]}…")
+
+# El mensaje reenviado tiene que ser IDÉNTICO en forma al del kernel: si el relay lo reescribiera,
+# libudev-zero sintetizaría propiedades distintas de las que sintetiza al enumerar, y un
+# dispositivo se comportaría distinto según si estaba enchufado al arrancar o no.
+if not texto.startswith(("add@", "change@", "remove@", "bind@", "unbind@", "move@", "online@", "offline@")):
+ fallar(f"el mensaje reenviado no empieza con la línea de acción del kernel: {texto[:60]!r}")
+print(" ✓ el mensaje va tal cual lo emitió el kernel (misma forma ⇒ mismas propiedades sintetizadas)")
+
+print("\n✓✓ probar-uevent-relay: el hotplug llega a donde libudev-zero lo está esperando")