Estoy tratando de agregar algunas opciones a ZinjaI para poder controlar la forma en que ejecuta los programas que compila. Actualmente, gracias a los toolchains alternativos se puede modificar por completo la forma en que se compila un proyecto (utilizando un makefile personalizado, o hasta un script). Ahora quiero poder hacer algo parecido con la ejecución, pero aquí hay ciertos detalles interesantes a tener en cuenta. ¿Para qué serviría esto? Algunos ejemplos son casos donde lo que ZinjaI compila es parte de algo más grande y lo que en realidad hay que ejecutar es ese algo (como los organismos para la comvida por ejemplo). En otros casos hay que correr el ejecutable a través de una herramienta (por ejemplo al utilizar mpi para paralelizar, a través de mpirun). Pero el caso más útil será cuando simplemente se quiera hacer algo antes de la ejecución, como borrar/crear archivos, o definir variables de entorno, para luego ejecutar normalmente. El problema es que hay algunas particularidades de cómo se lanzan nuevos procesos hijos desde un proceso padre en GNU/Linux, y de cómo se gestionan los entornos en los shells que interpretan los scripts, que me han dado más de un dolor de cabeza. Finalmente llegué a una forma muy simple de hacer más o menos lo que quería, forma que no pude encontrar buscando en Google, y por eso vengo a comentarla.
Lo que me interesa poder hacer en un proyecto en ZinjaI, es plantear tres mecanismos de ejecución: uno es el actual, donde ZinjaI ejecuta el binario compilado y ya; otro es a través de un script que provee el usuario y que se encarga de todo (incluyendo de lanzar la ejecución del binario), y el tercero y más complicado es el que permitiría setear el entorno mediante un script, pero delegar luego la ejecución a ZinjaI. La necesidad de esta tercera alternativa radica en que si ZinjaI no ejecuta él mismo el binario compilado, se complican cosas como la depuración y otras tareas. ZinjaI necesita lanzar él al ejecutable para poder hacerlo a través de gdb (o de valgrind por ejemplo). Si lo hiciera un script, no se podrían redireccionar las entradas/salidas del depurador sin interferir con el resto del script. En ese caso, la opción que queda es dejar que el script lance el binario, y más tarde "adjuntar" el depurador a ese ejecutable ya en marcha. La posibilidad de adjuntar el depurador a un proceso en marcha ya está disponible desde hace tiempo en la versión de ZinjaI para GNU/Linux (en Windows no, por limitaciones y errores del port de gdb a Windows), pero el mecanismo no es transparente al usuario y utilizarlo requiere algunas consideraciones especiales.
Entonces, resumiendo: si ZinjaI ejecuta, no cambia nada; si el script ejecuta, ZinjaI solo se encarga de lanzar el script, lo cual es fácil, pero ya no puede controlar la depuración y algunas otras cosas; el caso interesante es cuando quiero que ZinjaI ejecute primero el script y luego el binario. ¿Y por qué es difícil? Porque si hago lo que dije de la forma estándar, las variables de entorno que modifique el script no servirán para la ejecución del binario, porque el script se ejecuta en su propio entorno. Un proceso hijo crea una copia del entorno de su padre y trabaja sobre la copia. Y no encontré ninguna forma de que el shell (proceso hijo) modifique el entorno del proceso padre (ZinjaI, el runner, gdb, quien sea).
Veamos un poco en detalle cómo se lanzan los procesos en GNU/Linux. Para lanzar un proceso hijo hay que hacer dos cosas: fork y exec. Fork duplica el proceso actual (el padre), y ahora tenemos dos procesos que comparten el mismo espacio de memoria y muchas otras cosas. Comparten entonces también el mismo entorno (se puede verificar fácil haciendo forkv y seteando una variable con putenv en donde se debería lanzar al "hijo"). Luego exec (o una de sus variantes, usualmente execvp) reemplaza uno de los dos procesos con el nuevo binario que queremos ejecutar. Al hacerlo crea un nuevo espacio de memoria (y entorno) para este nuevo proceso, y ahí está el problema. Por eso, si ejecuto el script, las variables de entorno que cambié se pierden al finalizar el mismo, porque fueron hechas en este nuevo entorno, que era copia del entorno del padre, pero no era el mismo. La alternativa complicada a fork es clone, permite controlar más cosas, pero el problema está en exec y no en fork, y ninguna de sus alternativas me lo solucionó. La opción más simple que la combinación fork/exec es usar la función estándar de C system, que hace todo esto por dentro, pero utilizando un shell. Es decir, no ejecuta directamente el comando que le pasemos, sino que lanza un shell (mediante fork/execvp) y le pide a ese shell que lo haga (mediante el argumento -c para el ejecutable del shell, que busca en /bin/sh). Este detalle será importante en breve.
El otro detalle importante es la forma de hacer, dentro de un shell, que un script modifique sus variables de entorno. Si en un shell lanazamos un script (por ejemplo, escribiendo "./nombre_del_script.sh" en una terminal), el script se lanza en un nuevo (sub)shell hijo del primero, y entonces las variables que el script modifique no se ven luego de que el script finalice. Pero si lo ejecutamos anteponiendo un punto y un espacio a la llamada, el script se evalúa linea a línea en el shell actual en lugar de utilizar un shell nuevo (por ejemplo, escribiendo ". ./nombre_del_script.sh"). Este es un truco no tan conocido por el usuario promedio de la terminal (a mi me llevó años encontrarlo), pero a veces muy útil. Si el archivo del script tiene permisos de ejecución, y el contenido utiliza el comando "export", entonces los cambios que el script haga en las variables se verán reflejados en el entorno del shell, y estas nuevas variables se podrán usar en los próximos comandos dentro del mismo.
Sumando ambas cosas, una forma de hacer que un script modifique un entorno y luego en este entorno modificado se ejecute un binario, sería ejecutar en un shell el script anteponiendo ". " y luego en el mismo shell el binario. Esto lo puedo hacer desde ZinjaI, reemplazando el comando que ejecuta normalmente (la llamada al ejecutable de un proyecto, al runner, o a gdb), por una llamada a un shell que haga estas dos cosas. Por ejemplo, si ZinjaI quiere depurar un binario en "debug.lnx/MiProyecto.bin" con el entorno modificado por el script "entorno.sh", en lugar de ejecutar "gdb --varias-opciones debug.lnx/MiProyecto.bin" como hace normalmente, puede ejecutar "/bin/sh -c '. ./entorno.sh; gdb --varias-opciones debug.lnx/MiProyecto.bin'". Mientras el script no haga nada con la entrada/salida estándar, ZinjaI puede comunicarse con este proceso sh como si fuera el depurador, sin hacer nada especial ni diferente más que esa linea. Si el script hace alguna salida por consola, podemos anularla redireccionandola a /dev/null. El único problema sería si el script requiriese alguna entrada por consola, y por ahora en ese caso habrá que usar el otro mecanismo (ceder la responsabilidad de ejecutar el binario al script, y adjuntar el depurador manualmente luego).
En conclusión, esto de los entornos y los shells es un lio interesante. Yo tenía alguna idea básica de porqué no se veían las variables modificadas luego de ejecutar un script, y de cómo funcionaba fork/exec, pero no tenía claros los detalles ni sabía cuales eran o de dónde venían las limitaciones. A bajo nivel, tiene muchísimo sentido que un proceso hijo no pueda en la mayoría de los casos, modificar el ambiente del padre, pero pensé que habría alguna forma fácil de hacer la excepción, y no la hay o no la pude encontrar (diría que no la hay por las respuestas de gente que pareciera que sabe mucho que vi en stack-overflow y otros foros). Estuve experimentando mucho con estos comandos, y por ahora es la mejor solución que encontré. Se parece bastante a lo que quería, pero hubiese sido mejor si el runner hubiese podido ejecutar el script en su propio entorno (pero el runner no es un shell). Y todavía tengo que ponerme a ver cómo es todo esto en Windows (investigar un poco CreateProcessEx creo).
No hay comentarios:
Publicar un comentario