Hay que corregir los `assert`
(kristoff.it)- assert es un mecanismo para expresar en el código precondiciones, postcondiciones e invariantes, y cuando una restricción puede imponerse con el sistema de tipos, es preferible expresarla con las funciones del lenguaje
- En Zig,
std.debug.assertno es un macro sino una función normal, que marca rutas imposibles medianteunreachabley también se aprovecha para optimización - En Debug y ReleaseSafe, un assert fallido hace que el programa se cierre con panic, pero en ReleaseFast y ReleaseSmall puede provocar comportamiento ilegal no verificado y hacer que funcione mal
- Si desactivas los assert en producción, pierdes la oportunidad de descubrir rápidamente suposiciones erróneas, y luego el código puede terminar dependiendo de assert incorrectos hasta convertirse en una vulnerabilidad
- Elegir entre ReleaseSafe y ReleaseFast depende de las prioridades del programa, pero el punto clave es no tapar el problema desactivando los assert, sino corregir los assert incorrectos
El papel de assert y el comportamiento predeterminado de Zig
- assert es un mecanismo para expresar en código que ciertas condiciones siempre deben ser verdaderas, como “este argumento no puede ser null” o “este entero no puede ser par”
- Ej.:
assert(my_arg != null);,assert(my_num % 2 != 0); - Si una restricción puede imponerse con el sistema de tipos, es mejor usar una función del lenguaje en lugar de un assert
- En Zig, un puntero normal
*Foono puede ser null, y un puntero opcional?*Foosí puede serlo, pero obliga a verificarlo antes de acceder al valor
- Ej.:
- Los assert son adecuados para expresar precondiciones, postcondiciones e invariantes
- Un buen assert puede ser más poderoso que una prueba unitaria para detectar errores de programación
- Su efecto puede aumentar todavía más si se usa junto con fuzzing
unreachable y los assert en Zig
- El assert de Zig se basa en
unreachable, una función del lenguaje que marca rutas de código inválidas- En un
switch, una rama imposible puede marcarse como.a => unreachable unreachablepuede usarse tanto como sentencia como en lugares donde se requiere una expresión de cualquier tipo- No hace falta fabricar a la fuerza un valor temporal cuando algo no debería poder alcanzarse
- En un
std.debug.assertde la biblioteca estándar de Zig está implementado asípub fn assert(ok: bool) void { if (!ok) unreachable; // assertion failure }- La información de
unreachablepuede aprovecharse para optimización- El compilador puede eliminar rutas inalcanzables, y esa información puede propagarse para permitir optimizaciones no locales
- No todos los assert mejoran el rendimiento, pero sí pueden habilitar optimizaciones que el programador no anticiparía fácilmente
Modos de compilación y seguridad en tiempo de ejecución
- Zig tiene modos de compilación Debug, ReleaseSafe, ReleaseFast y ReleaseSmall
- Esta configuración no necesariamente se aplica solo de forma global a todo el programa
- Cada dependencia puede compilarse en un modo distinto, y con
@setRuntimeSafetytambién puede ajustarse la seguridad en tiempo de ejecución por bloques dentro de una función
- En Zig, la falla de un assert se considera “illegal behavior”
- En los modos checked —Debug, ReleaseSafe y
@setRuntimeSafety(true)— el programa se cae con un panic - En los modos unchecked —ReleaseFast, ReleaseSmall y
@setRuntimeSafety(false)— ocurre “unchecked illegal behavior” y el programa se comporta mal
- En los modos checked —Debug, ReleaseSafe y
- El resultado del unchecked illegal behavior no está garantizado
- En el
switchdel ejemplo, por las características del código máquina generado actualmente, puede parecer que salta a otra rama - En otra versión del compilador, el comportamiento erróneo podría ser completamente distinto
- Puedes ver el comportamiento relacionado en este ejemplo de godbolt
- En el
- También puede verse en otro ejemplo de godbolt cómo cambian el assert y el
switchposterior entre ReleaseSafe y ReleaseFast- En ReleaseFast, aparece una forma en la que la función se salta todas las comparaciones y devuelve
true - Este tipo de optimización es justo del que dependen mucho los videojuegos y otras aplicaciones multimedia en tiempo real
- En ReleaseFast, aparece una forma en la que la función se salta todas las comparaciones y devuelve
El assert de Zig no es un macro
std.debug.assertde Zig no es un macro, sino una función normal- Zig no tiene macros
- Este suele ser un punto especialmente sorprendente para desarrolladores de C/C++ que se acercan a Zig
- En C/C++, al desactivar assert suele ser común que toda la llamada y la expresión pasada actúen como si quedaran comentadas
- Por eso, en C/C++ no conviene poner en assert expresiones con efectos secundarios
- Si assert se desactiva, esa operación en sí puede desaparecer
- En Zig, según las reglas de llamada a funciones, los argumentos se evalúan antes de llamar a la función
- Independientemente de la lógica interna de
std.debug.assert, la expresión del argumento sí se evalúa - Por eso pueden ponerse en assert expresiones con efectos secundarios, como en este caso
// assert that the remove operation is not a noop: assert(my_map.remove("expected-to-exist")); - Independientemente de la lógica interna de
- En cambio, si calcular la condición del assert requiere una operación compleja, en modo unchecked ese cálculo no necesariamente se eliminará
- En esos casos, conviene proteger el código con
comptime if
const builtin = @import("builtin"); if (builtin.mode == .Debug) { var condition = ...; // whatever bookkeeping is necessary // to compute the condition assert(condition == .ok); } - En esos casos, conviene proteger el código con
- Si vienes de la semántica de C/C++, esto puede resultar extraño, pero en Zig se parte de la idea de que normalmente no se desactivan los assert
El problema de desactivar assert en producción
- En términos generales, hay tres opciones con los assert
- Mantenerlos como chequeos en tiempo de ejecución y hacer que, si fallan, el proceso se cierre con panic
- Usarlos para optimización de rendimiento y aceptar que, si el assert es incorrecto, el programa pueda comportarse mal
- Desactivarlos por completo
std.debug.assertno ofrece soporte predeterminado para desactivar por completo los assert- Si implementas tu propio assert que revise una bandera de compilación, puedes acercarte más al comportamiento típico de C/C++
- Normalmente, querer desactivar assert es el resultado de combinar dos motivos
- No quieres mantener el chequeo en tiempo de ejecución por su costo de rendimiento o por no querer que la aplicación se caiga
- No confías del todo en que el assert sea siempre correcto, y temes el mal funcionamiento que podría producirse si se usa para optimización
- Como recordó matklad en una discusión relacionada, sí existen situaciones con razones legítimas de ingeniería para evitar un crash
- Pero en el software general, tomar por defecto la evasión del crash se considera una mala elección
- Si desactivas un assert, aunque se dé una condición que se asumía imposible, el programa sigue ejecutándose
- El programa sigue funcionando bajo una suposición falsa, y eso ya es una forma de mal funcionamiento aunque no llegue a ser unchecked illegal behavior
- Lo peligroso del unchecked illegal behavior o del undefined behavior de C es que pueden abrir la puerta a convertir el programa en una weird machine
- En software lo bastante complejo, incluso sin UIB, el programa puede torcerse de formas no previstas
- Que un assert resulte falso en tiempo de ejecución significa salirse de la especificación, y eso por sí solo puede hacer que ejecute tareas no deseadas
- La inyección SQL es un ejemplo concreto y muy extendido de mal funcionamiento tipo weird machine sin necesidad de UIB
- Si el costo de un mal funcionamiento del programa es demasiado alto, entonces conviene dejar los assert activados
- Si el rendimiento es tan importante que puedes aceptar el riesgo de mal funcionamiento, entonces tiene sentido usar assert como oportunidad de optimización
- Si desactivas assert, es fácil perder rendimiento y además engañarte creyendo que estás más seguro de lo que realmente estás
Cómo los assert incorrectos engañan a la base de código
- El riesgo central es que un assert incorrecto puede no mostrarse en las pruebas y fallar solo en producción
- Si pudiera garantizarse que todos los assert son siempre verdaderos, no habría discusión sobre usarlos para optimización
- Si pudiera garantizarse que las pruebas detectan todos los assert incorrectos, la optimización en producción también sería segura
- En la práctica, pueden escribirse assert incorrectos y las pruebas no necesariamente los detectan
- Si desactivas los assert en producción, pierdes la oportunidad de descubrir un assert incorrecto lo antes posible
- Un problema aún más grave es que el código posterior puede seguir escribiéndose dependiendo de ese assert incorrecto
- En el código de ejemplo, se asume mediante un assert que
processThingsolo debe llamarse con unthingque ya fue iniciadofn processThing(thing: Thing) void { // this function must always be invoked on // a thing that has already been started assert(thing.is_started); // ... } - Ese assert podría no fallar en pruebas, y al estar desactivado en producción podría pasarse por alto que en realidad sí puede ser falso
- Si no hay un mal funcionamiento visible para el usuario, puede parecer que no existe problema y el desarrollo continúa
- Después, alguien podría agregar código suponiendo que
thingya fue iniciado y que por eso puede llamar abazsin preparación adicionalfn processThing(thing: Thing) void { // this function must always be invoked on // a thing that has already been started assert(thing.is_started); // ... // Since thing is already started, we don't // need to foo the bar before bazzing the qux. // It would be really bad to baz the qux otherwise, // so we add an assert for good measure. assert(thing.is_fooed); thing.baz(qux); } - Aunque el segundo assert sea lógicamente correcto por sí mismo, si el primero en realidad puede ser falso, aparece el riesgo
- En pruebas, como el primer assert no falla, el segundo tampoco falla
- En producción, con assert desactivados, puede que nadie note el momento en que esa vulnerabilidad entra a la base de código
- Si los assert dentro del código están engañando a los desarrolladores, escribir código correcto se vuelve injustificadamente difícil
Las opciones dependen de las prioridades del programa
- Cada programa tiene prioridades distintas, y en algunos puede estar justificado priorizar el rendimiento sobre minimizar el riesgo de mal funcionamiento
- En ese caso, convertir assert en oportunidad de optimización es una decisión natural
- Desactivar assert en producción por inercia se considera una decisión peor que dejarlos activados, e incluso peor que explotar activamente las optimizaciones de rendimiento
- Zine es un generador de sitios estáticos y hoy se usa principalmente para compilar blogs personales
- No tiene un modelo de amenazas bien definido, y ese tampoco es su principal objetivo
- Por preferir que corra un orden de magnitud más rápido que Hugo, distribuye builds ReleaseFast
- Awebo es una alternativa a Discord autoalojable en etapa pre-alpha
- Ya está claro que maneja información privada y será software expuesto a internet
- Por eso, planea ofrecer builds ReleaseSafe al momento de distribución
- Aun así, algunas dependencias clave como FFmpeg, Xiph Opus y SQLite se compilarán en ReleaseFast
- En esas dependencias se considera claramente más importante la mejora de rendimiento que reducir aún más el riesgo de mal funcionamiento del programa
Decisiones en proyectos reales y casos de seguridad
- TigerBeetle es una base de datos financiera y mantiene los assert siempre activados
- Ghostty es un emulador de terminal y distribuye builds ReleaseFast para macOS
- También recomienda el mismo enfoque a consumidores downstream, por ejemplo administradores de distribuciones Linux
- Dos CVE públicas relativamente serias de Ghostty fueron casos donde era posible ejecutar comandos arbitrarios sin corrupción de memoria
- La corrupción de memoria o el UIB no son todo el riesgo
Los assert implícitos de Zig no desaparecen por completo
- Aunque puedas desactivar tus propios assert, no puedes desactivar los assert que el propio lenguaje Zig agrega implícitamente al código
- Entre ellos están el overflow de enteros, la división por cero y el acceso fuera de rango en arreglos
- Esas condiciones provocan panic en tiempo de ejecución o se usan con fines de optimización
- La práctica de desactivar assert en producción puede hacer que los assert incorrectos se pudran y se multipliquen dentro de la base de código
- Como resultado, puede crecer la paranoia frente al UIB, y los desarrolladores podrían terminar temiendo inconscientemente volver a activar los assert y enfrentarse al resultado
- La conclusión inevitable no es tapar el problema desactivando assert, sino corregir los assert incorrectos
- La corrección del programa debe buscarse en el conjunto completo, no solo en algún subconjunto
1 comentarios
Comentarios de Lobste.rs
Coincido en que, por lo general, lo mejor es que
assertsimplemente provoque un crash, o que solo haga caer la tarea, como el pánico de Rust. Pero me cuesta aceptar que usarassertcomo pista de optimización siempre sea mejor que simplemente quitarloPrimero, muchas veces un
assertarbitrario no ayuda mucho a optimizar, y hay muchas condiciones que el optimizador no puede aprovechar de inmediato. A menos que introduzcas una suposición directa como “esta rama nunca se ejecuta”, es poco probable que obtengas grandes ganancias de rendimiento esparciendo supuestos aleatorios por todo el códigoSegundo, convertir
asserten una suposición amplía muchísimo el radio de impacto de un error. Por ejemplo, en un sistema que procesa datos separados por proyecto o por usuario, imagina que hay unasserten medio de una función de cálculo para detectar un estado que en teoría nunca debería ocurrir. Si en un build de lanzamiento se desactiva por su costo, al simplemente inhabilitarlo el daño puede quedar limitado a un solo proyecto o usuario, e incluso otra verificación posterior podría detectarlo. En cambio, si eso se convierte en comportamiento indefinido, el cálculo puede saltar a código incorrecto, corromper memoria arbitrariamente y dañar los datos de todos los proyectosAl final, si eliges
assertinseguros como valor predeterminado para los builds de lanzamiento, estás optimizando prematuramente puntos arbitrarios del código a cambio de reducir la posibilidad de aislar el daño cuando algo sale mal. Me parece que Rust está bien diseñado en esto:assert!()siempre hace pánico,debug_assert!()solo hace pánico en modo debug, yassert_unchecked()hace pánico en debug y se vuelve una pista de optimización en releaseReleaseSafey noReleaseFastassertindividuales, sino a desactivarlos masivamente como práctica general recomendadaEs totalmente razonable concluir que el impacto en rendimiento es demasiado grande como para mantenerlos en builds de lanzamiento. Además, como decía antes, un
assertcostoso de evaluar casi nunca va a traducirse en una mejora de rendimientoEn Zine también hay varios ejemplos de eso:
https://github.com/kristoff-it/zine/…
https://github.com/kristoff-it/zine/…
En Zig no existe un “modo release predeterminado”. Siempre tienes que elegir tú mismo cómo tratar
assert, y las opciones globales son crash u optimización; ninguna de las dos puede considerarse más predeterminada que la otraMe resulta muy extraño que los dos CVE relativamente serios publicados hasta ahora en Ghostty hayan terminado en ejecución arbitraria de comandos sin corrupción de memoria. Que además se haya distribuido con
ReleaseFastcontradice de frente mi comprensión de cómo funciona el mundoComo alguien que ha trabajado con emuladores de terminal, estas vulnerabilidades son exactamente el tipo de problema molesto que uno espera. No lo digo para menospreciar a desarrolladores o investigadores; este tipo de inyección de comandos en lugares insólitos casi viene de serie en este campo, igual que en otras áreas aparecen otras vulnerabilidades de inyección
Es curioso llevar casi 40 años escuchando el argumento de que en producción hay que desactivar
asserty las verificaciones de límites “por rendimiento”. En ese tiempo las computadoras se han vuelto varios órdenes de magnitud más rápidas y el software se ha metido mucho más en la vida de todos, así que la corrección en tiempo de ejecución es más importante que nuncaPara hablar de algo más productivo, en Microsoft antes había, además de los
assert,checky similares habituales, unos asserts de reporte que no he visto mucho en otros lados. Se usaban cuando había una condición que no controlabas por completo, asumías que era cierta, pero aun así manejabas de forma defensiva el caso en que fuera falsa y querías saber mediante logs o telemetría remota si en la práctica llegaba a ser falsa. Por ejemplo, cuando asumes que un usuario no pondrá más de 1000 elementos en cierta lista y por eso usas un algoritmo cuadrático, o cuando das por hecho que la latencia de red será menor a 200 ms y por eso eliges un protocolo con muchos viajes de ida y vueltacheck?Como una de las personas enlazadas aquí, diría que esto convierte mis ideas sobre
asserten una falsa dicotomía ridícula y caricaturesca. Como ya escribí en otro comentario, prefiero decidir caso por caso si conviene convertir algo en comportamiento indefinido. Mi crítica a ReleaseFast es que agrupa esa decisión no solo para todos losassertde cierto alcance, sino también para todas las verificaciones de seguridadCoincido con kristoff en que es tonto desactivar
assertsin corregirlos solo porque provocan crashes. Pero no estoy de acuerdo en que “crash o comportamiento indefinido” sean las únicas alternativas razonables. La postura de goldstein en el comentario hermano se parece más a lo que yo piensoEs difícil defender que el comportamiento de
assert_unchecked()se vuelva el valor predeterminado global, pero como técnica de optimización de rendimiento puede ser razonable. Si convertir todos losasserten suposiciones hace que el build de producción sea mucho más rápido, puede que haya unas pocas suposiciones, ojalá solo una, que generen casi toda la mejora, y podrían encontrarse con algo como una búsqueda binariaReleaseSafeyReleaseFast/ReleaseSmallEn la literatura de análisis de programas existe una dualidad que divide las afirmaciones o
assertdentro del código en dos formas. Una corresponde al contexto alrededor del código; en una función, serían las condiciones que debe satisfacer quien la llama. La otra corresponde al código mismo; en una función, serían las condiciones que la función debe satisfacer.Esta distinción se vuelve clara si se mira desde el concepto académico estándar de “culpa” (blame) en la literatura sobre contratos y tipos graduales. Si falla una afirmación sobre el contexto, no es culpa nuestra sino del contexto o del llamador, aunque también puede ocurrir que el llamador tenga razón y la afirmación misma sea el bug. Si falla una afirmación sobre el código mismo, la responsabilidad es nuestra, aunque también puede ocurrir que el código esté bien y la afirmación misma sea el bug.
A nivel de función, una precondición es una afirmación sobre el contexto y una postcondición es una afirmación sobre el código. Pero ambas también pueden insertarse en medio del código. Algunos frameworks de verificación usan
assertpara afirmaciones sobre el código yassumepara afirmaciones sobre el contexto. Esto también se relaciona con cómo lo interpretan algunos frameworks de testing, especialmente los de pruebas aleatorias. Siassertfalla, se marca como fallo de prueba; siassumefalla, se omite la prueba.Esto parece insinuar a Bun, así que quisiera dejar la conexión un poco más formal. Existe un issue de Zig de 2024 en el que Jarred Sumner, creador de Bun, propuso que
unreachablehiciera panic en ReleaseFast. Los comentarios de Andrew Kelley y Matthew Lugg en ese hilo son relevantes para esta discusión.=> https://github.com/ziglang/zig/issues/19664
Bun usa sus propias funciones
assert, y en modo release hacen panic o se eliminan, pero no introducen comportamiento indefinido. Aun así, también hay que recordar la nota al pie de Loris: “como lenguaje, Zig agrega implícitamente muchosassertque no se pueden desactivar al código”.No quiero extenderme demasiado con Bun, porque es un proyecto único de un equipo pequeño. El punto clave es que, si existe la más mínima preocupación, hay que usar ReleaseSafe. ReleaseSafe tiene fama de ser lento, pero en mis proyectos pequeños en Zig no he podido medir una diferencia de benchmark entre ReleaseSafe y ReleaseFast. Aun así, probablemente siga siendo más rápido que muchos otros lenguajes.
O, si tiene sentido en ese contexto, podrías distribuir un ejecutable en ReleaseFast y, si empiezan a llegar reportes de bugs no deterministas por comportamiento indefinido, volver a ReleaseSafe. Entonces podrás reunir reportes de bugs accionables sobre qué
assertfalló, incluyendo también accesos fuera de rango u overflows, y corregir el código. Incluso si al principio decidiste distribuir en un contexto donde nunca debiste usar ReleaseFast, igual recomendaría este enfoque :^)También se puede aplicar la misma estrategia solo a partes del proyecto ajustando dependencias y usando
@setRuntimeSafety. Al final, si realmente quieres hacerlo con inteligencia, ya tienes todas las herramientas necesarias.No debería escribirse como si estuviera bien poner expresiones con efectos secundarios dentro de una llamada a
assert. Es una mala práctica. También conviene evitar usarassertpara verificar errores. Siendo justos, no parece que el autor esté defendiendo eso.Por el contrario, también se explica que si
assertdepende de un cálculo complejo, ese cálculo no necesariamente se elimina en modo unchecked, así que conviene protegerlo concomptime if.Espero que el autor no haya pasado por alto la ironía de decir que es “una buena oportunidad para dejar atrás el trauma que dejaron los macros y abrazar la simplicidad”. Lo que en realidad se estaría proponiendo abrazar es “la simplicidad” de tener que considerar el modo de compilación del programa y esparcir
comptime ifdefensivos por todas partes.He escrito algo de código de cálculo numérico en C# y uso muchos
assertque se desactivan en release. Son demasiado costosos para ejecutarlos en cada loop apretado, pero en pruebas unitarias es útil que revienten apenas la rutina vea por primera vez una entrada NaN.A menudo esos NaN no vienen de la entrada del usuario, sino de bugs en el código, como cuando el optimizador se va por caminos donde no debería, y entonces hacen falta mejores restricciones de borde. Claro, puede ser que la entrada del usuario necesite saneamiento, pero eso debe hacerse en el límite más externo, no en lo profundo del algoritmo. Sería bueno contar con un sistema de pruebas que permitiera demostrar los invariantes internos del algoritmo sin
assertcomo resultado del saneamiento de la entrada del usuario, pero esto es un proyecto paralelo y si explota nadie va a morir.El 90% de los desacuerdos sobre
assertexisten porque la definición de esa palabra es pobre y múltiple, y eso enturbia tanto el pensamiento como la comunicación. Por eso hay que separar el concepto en tres nombres y usarlos con rigor.assert(bool)o, en Rust,assert_unchecked(), es algo que el programador cree que siempre es verdadero, y que el compilador también asume como siempre verdadero para usarlo en optimizaciones. Para evitar la asociación con el assert verificable de los lenguajes antiguos, quizás sería mejor llamarloassume().check(bool)hace panic si la condición es falsa y continúa si es verdadera, y siempre funciona así.debug_check(bool)es igual quecheck()en modo debug, y en modo release siempre continúa. En la práctica, se controla con un flag--debug_checksactivado por defecto en modo debug.Además, también hace falta un flag del compilador
--check_assertsque conviertaassert()encheck(). Sirve cuando quieres verificar tus propiosassertporque te generan dudas, y en modo debug estaría activado por defecto. Si al decir “assert” no se deja absolutamente claro a qué se refiere uno, es imposible tener una discusión madura y solo se termina desperdiciando palabras.