tawasuyu

sergio/takana

> 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>

5b761c17554ac412dde80ab2c2f77657fd1a90fe · por sergio · (hace 6 d) · padre 74a6c5e3b · ver el árbol en este commit

Delta semántico · 22

crates/uevent-relay/src/main.rs

scripts/probar-uevent-relay.py

5 archivos · +501 −0

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")