miércoles, 8 de agosto de 2012

El runner, la terminal y las señales

Hace muy poquito escribí acerca del uso de la función signal de C para interceptar un segmentation fault. En estos días estuve tratando de utilizarla para otras cosas y eso me llevó a notar algunos problemas en el runner de ZinjaI. El runner es un pequeño ejecutable que utiliza ZinjaI para ejecutar indirectamente los programas y proyectos que compila, a modo de wrapper. Desde el punto de vista del usuario el runner es casi invisible, pero básicamente se encarga de cambiar el directorio de trabajo, informar cómo terminó la ejecución (con qué código de salida), y esperar una tecla si es necesario para que no se cierre la terminal.

Cuando uno ejecuta directamente (no mediante ZinjaI) un programa de consola en Windows, este se ejecuta en una de esas ventanas negras y se cierra inmediatamente cuando termina. En GNU/Linux es peor aún, ya que si estamos en el entorno gráfico ni siquiera se toma la molestia de abrir una ventana. En GNU/Linux, ZinjaI llama a una terminal gráfica para que la cosa se vea, y le pide al runner que, entre otras cosas, espere una tecla después de que termine el programa, para que el usuario pueda ver qué resultado arrojó. Básicamente ZinjaI pide a una terminal gráfica (xterm o konsole por ejemplo) que ejecute el runner, y éste hace un fork (se "divide" en dos procesos idénticos, se llama padre al original e hijo al nuevo), ejecutando al programa que realmente queríamos ver en el nuevo proceso hijo. El proceso original espera a que termine el hijo, y analiza su código y estado de salida, que puede ser el valor de retorno del proceso (usualmente el return 0 del final), o el número de la señal que lo obligó a detenerse de forma anormal. Hasta aquí todo más o menos normal. Eso es lo que hace el runner en las versiones de ZinjaI estables. Pero jugando con las señales me surgió un nuevo problema: cuando mando una señal desde el teclado en la terminal, ambos procesos la reciben.

Antes de explicar mejor el problema, hay que aclarar por las dudas que hay muchas otras señales además de la que se genera por un segmentation fault. Por ejemplo, cuando se presiona Ctrl+C para interrumpir un programa (SIGINT), cuando se presiona Ctrl+Z para enviarlo a segundo plano (SIGTSTP), cuando se intenta cerrar la ventana haciendo click en la X de arriba a la derecha (SIGHUP), etc. Estas y muchas otras señales pueden ser interceptadas por el programa con la función signal. Por ejemplo, usualmente un programa se cierra la recibir SIGHUP, pero podemos interceptarla para pedir confirmación si hay cambios sin guardar. Para ver una lista completa de señales se puede utilizar el comando "kill -l" en cualquier terminal de GNU/Linux. Algunas señales más particulares, como SIGKILL (matar sin preguntar) no pueden ser interceptadas. Pero con las que sí se puede, se puede hacer cosas interesantes. En el problema que amagué a presentar al principio, yo tenía un programa de consola que procesaba mucho rato antes de mostrar los resultados, y a veces no sabía si estaba trabajando bien o en un bucle infinito, o si faltaba mucho o faltaba poco, etc. Entonces pensé en hacer que muestre el "estado" al recibir una señal. Esto es mejor que mostrarlo en cada iteración con la sobrecarga que eso implica (cout y cerr no son especialmente rápidos), y porque además no quería modificar la salida para casos normales ya que el programa se invoca a veces desde otro script que parsea sus resultados. De esta forma, dejo que corra normalmente, y si en algún momento me resulta sospechosamente extraña la demora presiono Ctrl+Z generando una señal que invoca a la función que imprime el estado en pantalla para ver como va, con la única contra de que para hacerlo tuve que agregar alguna que otra variable global.

El problema real surgió cuando mi método no funcionó. Después de plantear y probar unas cuantas hipótesis llegué a la conclusión de que el problema era el runner. En la terminal que abre ZinjaI al ejecutar el programa (si el proyecto está marcado como "de consola") el proceso padre es el del runner, y no el del programa que uno está probando. Lo que pasaba era que el runner recibía la señal, la procesaba y finalizaba, cerrándose así la terminal y finalizando mi programa también en lugar de mostrarme la información que quería. Tuve que modificar el runner para que intercepte todas las señales y las ignore. Primero pensé en que debía reenviarlas al proceso hijo, pero resultó que la recibían ambos (al menos con xterm), así que no fue necesario. La única señal con tratamiento especial fue SIGHUP, la que se recibe cuando el gestor de ventanas quiere cerrar la ventana. En este caso el runner no espera la tecla luego de finalizado el proceso hijo, de forma que efectivamente se cierra la ventana (sino sería molesto), a menos que el proceso hijo la intercepte.

Pero faltaba un problema más. Cuando un proceso maneja las señales más comunes para evitar cerrarse, resulta difícil de cerrar. Es decir, si maneja la señal SIGHUP puede que no muera el cerrar su ventana. Si maneja la señal SIGINT no muere al presionar Ctrl+C, etc. Entonces puede ocurrir que muera el proceso padre y la terminal, pero quede vivo el proceso hijo, ejecutándose, consumiendo recursos, y molestando sigilosamente sin ser visto. Esto ya pasaba, y de peor forma antes del manejo de señales. Lo que ocurre ahora es que con la mayoría de las señales la ventana de la terminal ya no se cierra en estos casos, dejando el proceso andando todavía a la vista. Para cerrarlo definitivamente, hay que usar botón de Stop en ZinjaI (Ejecutar->Detener). Pero ¿cómo hacer para detener todo con ese botón? Todo implica matar al proceso hijo, terminar de inmediato el padre y cerrar la ventana de la terminal. ZinjaI dispone del pid de la terminal, los demás habría que averiguarlos indirectamente. En un caso normal, con matar forzosamente la terminal (con SIGKILL que no se deja desviar con signal) alcanza, ya que al cerrarse la terminal se genera la señal SIGHUP que hace finalizar a los otros dos. Pero cuando el proceso hijo maneja esta señal sin cerrarse aparece el problema. Como el proceso padre sí se cierra, lo que hice fue verificar en el padre si al recibir esa señal en particular la terminal (que es el proceso padre del padre) sigue viva, en cuyo caso indica que el usuario quiso cerrar la ventana mediante la X, y no que ZinjaI quiso matar a los tres procesos. En ese caso no se hace nada, pero si el padre del padre no sigue vivo, será porque ZinjaI mató esa terminal para finalizar todo, y entonces el primer padre (runner) se encarga de matar al hijo, también con SIGKILL.

Como ven, si es que se entendió algo, el manejo de señales al principio parece simple pero se complica. La complejidad genera dolores de cabeza, pero también se puede aprovechar para hacer muchas cosas. Aquí hay sólo algunos ejemplos. Si quieren probar los cambios del runner tendrán que recurrir al repositorio git. Y además todo esto aplica en GNU/Linux. No tengo mucha idea de cómo funciona la cosa en Windows. La función signal es estándar y las señales existen, pero no son las mismas, y no se producen igual. Por ejemplo, ya intenté hacer un buen lío para agregar el botón de pausa en la depuración, botón cuya función quería que sea en realidad simular un Ctrl+C en el proceso que está siendo depurado (ya que gdb se detiene al interceptar ciertas señales), pero no pude culpa de que el Ctrl+C en Windows no es realmente una señal, sino que se genera de otra forma. Esa forma no es fácil de emular desde un proceso hacia otro, encontré algunas implementaciones que usaban trucos interesantes (como falsear el stack del proceso) pero que no siempre funcionaban, y por eso terminé usando otro camino que encontré más tarde y que solo anda desde Windows XP en adelante (y no por ejemplo en Windows 2000, pero eso ya casi no importa). En algún otro post escribiré sobre ese problema en particular porque también tiene esquinas muy interesantes.

No hay comentarios:

Publicar un comentario