Historia de la pregunta
El rasgo UnwindSafe fue introducido en Rust 1.9 junto con std::panic::catch_unwind para abordar preocupaciones de seguridad ante excepciones heredadas de C++ y otros lenguajes con manejo de excepciones. En Rust, los pánicos desencadenan la recuperación de la pila que garantiza que se ejecuten las implementaciones de Drop, pero esto no asegura automáticamente que las estructuras de datos se mantengan en estados consistentes si un pánico interrumpe una operación lógica. El rasgo fue diseñado para marcar tipos que toleran estar en un estado activo a través de un límite de catch_unwind sin arriesgar comportamiento indefinido o errores lógicos.
El problema
Cuando una referencia mutable (&mut T) cruza un límite de catch_unwind y T contiene mutabilidad interior (como RefCell o Cell), un pánico puede dejar a T en un estado lógicamente inconsistente. Por ejemplo, si ocurre un pánico entre RefCell::borrow_mut y la disminución implícita del guardia RefMut resultante, el conteo interno de préstamos de RefCell permanece incrementado. Después de que catch_unwind captura el pánico y se reanuda la ejecución, RefCell parece estar prestado mutablemente, sin embargo, el guardia que disminuiría el conteo se ha eliminado durante la recuperación. Este estado "envenenado" constituye una violación de seguridad ante excepciones porque las operaciones subsiguientes sobre el RefCell provocarán un pánico o se comportarán incorrectamente, corrompiendo efectivamente el estado del programa de una manera que el código seguro no puede detectar o recuperar.
La solución
UnwindSafe sirve como un rasgo marcador conservador: se implementa automáticamente para la mayoría de los tipos pero se opta explícitamente por no hacerlo para &mut T y cualquier agregado que lo contenga. Al prohibir que &mut T implemente UnwindSafe, el sistema de tipos impide pasar referencias mutables a catch_unwind a menos que el programador las envuelva explícitamente en AssertUnwindSafe. Este envoltorio es un contrato inseguro donde el programador afirma que el tipo envuelto carece de mutabilidad interior o que ha verificado manualmente la seguridad ante excepciones. Esta elección arquitectónica obliga a una inclusión explícita en un patrón potencialmente peligroso, asegurando que la exposición accidental de un estado mutable, mutable por dentro, a través de límites de pánico se detecte en tiempo de compilación.
use std::panic::{catch_unwind, AssertUnwindSafe}; use std::cell::RefCell; fn main() { let shared = RefCell::new(vec![1, 2, 3]); // Esto no compila porque &mut RefCell no es UnwindSafe: // let _ = catch_unwind(|| { // let mut borrow = shared.borrow_mut(); // borrow.push(4); // panic!("interrumpido"); // }); // Opcionalidad explícita con reconocimiento inseguro: let result = catch_unwind(AssertUnwindSafe(|| { let mut borrow = shared.borrow_mut(); borrow.push(4); panic!("interrumpido"); })); // Después del pánico, shared puede estar en un estado de préstamo inválido, // pero reconocimos explícitamente este riesgo con AssertUnwindSafe. println!("Recuperado: {:?}", result.is_err()); }
Descripción del problema
Un servidor HTTP de alto rendimiento construido con hyper necesita aislar los pánicos en los controladores de solicitud definidos por el usuario para evitar que una sola solicitud malformada termine todo el proceso. El servidor mantiene un grupo de conexiones usando RefCell (para rendimiento en un solo hilo) para rastrear conexiones activas a la base de datos por hilo. La arquitectura envuelve cada controlador de solicitud en catch_unwind para capturar pánicos y registrarlos de manera elegante. Durante las pruebas de carga, el servidor encuentra un pánico en un controlador que mantiene un préstamo mutable del RefCell del grupo de conexiones. Cuando catch_unwind captura el pánico, la bandera interna de préstamo del grupo permanece configurada como "prestada mutablemente" porque el guardia RefMut fue eliminado durante la recuperación sin ejecutar su lógica de disminución. Las solicitudes subsiguientes en el mismo hilo intentan pedir prestado al grupo, provocando un pánico en tiempo de ejecución debido al estado ya prestado, lo que efectivamente bloquea el hilo y pierde el estado del grupo.
Solución 1: Eliminar catch_unwind y permitir la terminación del proceso
Este enfoque elimina por completo el problema de seguridad ante excepciones al permitir que el proceso se bloquee ante cualquier pánico, aceptando que la disponibilidad es secundaria a la corrección en este contexto específico.
Pros: Elimina completamente las preocupaciones de seguridad ante excepciones; ningún riesgo de corrupción del estado; simple de implementar.
Contras: Inaceptable para la disponibilidad de producción; una solicitud maliciosa o defectuosa termina con todo el servicio; viola requisitos de confiabilidad.
Solución 2: Reemplazar RefCell con Mutex y utilizar envenenamiento
Reemplazar el grupo basado en RefCell con Mutex<Pool> y aprovechar la detección de envenenamiento del mutex de Rust.
Pros: Mutex detecta pánicos en hilos que sostienen el mutex y se marca como envenenado, permitiendo que los intentos de bloqueo subsiguientes detecten corrupción a través de PoisonError; la biblioteca estándar proporciona seguridad incorporada.
Contras: Mutex introduce sobrecarga de sincronización innecesaria para ejecutores asíncronos de un solo hilo; requiere reestructurar el grupo de conexiones para que sea Send; el envenenamiento requiere lógica de manejo explícita para reinitializar el grupo.
Solución 3: Envolver controladores en AssertUnwindSafe con validación de estado
Mantener RefCell por rendimiento pero envolver el controlador en AssertUnwindSafe e implementar un guardia de eliminación personalizada que restablezca el estado de RefCell si ocurre un pánico.
Pros: Retiene los beneficios de rendimiento de RefCell; permite el aislamiento del pánico; posible implementar lógica de recuperación.
Contras: Requiere código inseguro para interactuar con AssertUnwindSafe; extremadamente difícil garantizar la seguridad ante excepciones para todos los caminos de código; fácil perder casos límite donde el estado permanece corrupto.
Solución elegida y razonamiento
El equipo seleccionó la Solución 2 (Mutex con envenenamiento) para el grupo de conexiones compartido, mientras que se utilizó la Solución 3 solo para buffers temporales específicos de la solicitud que pueden reinicializarse trivialmente. El mecanismo de envenenamiento explícito de Mutex proporciona una manera confiable y estandarizada de detectar corrupción sin requerir auditorías inseguras de cada posible punto de pánico. Se aceptó la pequeña sobrecarga de rendimiento a cambio de la garantía de seguridad.
Resultado
El servidor aísla exitosamente los pánicos en los controladores de solicitudes sin arriesgar la corrupción del estado. Cuando un controlador provoca un pánico mientras sostiene el bloqueo del grupo, el mutex se envenena y el servidor detecta esto en el siguiente acceso, descartando el grupo local corrupto y generando uno nuevo. Esto asegura que no ocurra comportamiento indefinido y que el servicio permanezca disponible incluso ante entradas adversas.
¿Por qué catch_unwind requiere UnwindSafe aunque Rust ejecuta destructores durante pánicos?
Muchos candidatos asumen que, dado que las implementaciones de Drop se ejecutan durante la recuperación, la seguridad ante excepciones está garantizada. Sin embargo, UnwindSafe aborda el estado lógico de los datos, no solo las fugas de recursos. Un pánico puede interrumpir una secuencia de operaciones (como actualizar un campo de longitud antes de los datos correspondientes), dejando un objeto en un estado temporalmente inconsistente. El destructor se ejecuta en este estado roto, lo que puede propagar la corrupción. UnwindSafe asegura que o bien el tipo no puede romperse por interrupción (datos inmutables) o que el programador reconoce el riesgo. Previene reanudar la ejecución con objetos que violan sus propias invariantes.
¿Cuál es la diferencia entre UnwindSafe y los auto-rasgos Send/Sync?
Mientras que Send y Sync también son auto-rasgos, utilizan razonamiento positivo: &T es Send si T es Sync, y &mut T es Send si T es Send. UnwindSafe utiliza razonamiento negativo: &mut T nunca es UnwindSafe, independientemente de T. Además, AssertUnwindSafe actúa como un escape a nivel de valor (similar a unsafe impl pero para valores específicos), mientras que las violaciones de Send/Sync generalmente requieren unsafe impl a nivel de tipo. UnwindSafe también se empareja con RefUnwindSafe para referencias compartidas, creando un sistema de doble rasgo similar pero distinto a Send/Sync.
¿Cómo crea la bandera de préstamo de RefCell inseguridad con pánicos, y por qué Mutex no tiene los mismos problemas de UnwindSafe?
RefCell se basa en una bandera de préstamo en tiempo de ejecución. Si ocurre un pánico entre borrow_mut() y el Drop del guardia, la bandera permanece configurada, pero el guardia se ha ido. Cuando la ejecución se reanuda, el RefCell parece estar prestado, pero no existe ningún préstamo real. Este es un error lógico que provoca que futuros préstamos provoquen un pánico erróneamente. Mutex evita esto implementando envenenamiento: si ocurre un pánico mientras se sostiene un bloqueo, el Mutex se marca como envenenado. Las llamadas a lock() subsiguientes devuelven un error que indica que el hilo anterior tuvo un pánico. Esto hace que la corrupción sea explícita y detectable, mientras que la corrupción de RefCell es silenciosa. Por lo tanto, MutexGuard es en realidad !UnwindSafe, pero el mecanismo de envenenamiento proporciona un camino de recuperación seguro que RefCell carece.