Un programa mío se quedó colgado cuarenta minutos. No a mil vueltas por segundo: a cero por ciento de CPU. No estaba trabajando de más; no estaba trabajando en absoluto. Y aun así no terminaba.
El programa hace algo común: orquesta herramientas de línea de comandos externas. Lanza una, lee lo que escribe en su salida estándar, y cuando termina pasa a la siguiente. Para no bloquearse si una herramienta se demora, cada una tiene un tiempo límite: al vencer, se mata el proceso y se sigue.
Esa es la parte que falló, y falló donde nadie mira: después de matar el proceso.
Matar el proceso no cierra la tubería
Cuando lees la salida de un subproceso, lees de una tubería: un extremo escribe (el subproceso), el otro lee (tú). Tu lector no termina cuando el subproceso muere. Termina cuando llega el EOF, y el EOF de una tubería llega solo cuando se cierra el último extremo de escritura.
Casi siempre coinciden: el subproceso es el único que escribe, muere, su extremo se cierra, llega el EOF, tu lector termina. Todo en microsegundos.
Pero "casi siempre" no es "siempre". La herramienta que yo lanzaba lanzaba a su vez otra —un nieto—. Y ese nieto heredó el extremo de escritura de la tubería, porque en Unix un hijo hereda los descriptores abiertos de su padre salvo que se diga lo contrario.
Así que al vencer el tiempo, maté al hijo. Su extremo se cerró. Pero el nieto seguía vivo, con su copia del descriptor abierta. El último extremo de escritura no se había cerrado. El EOF no llegó. Y mi lector se quedó esperando un EOF que no iba a llegar nunca —a cero por ciento de CPU, en un read() bloqueado, indistinguible de un trabajo lento—.
El síntoma que engaña
Lo que hace este fallo tan difícil de ver es que no se parece a un fallo. Un cuelgue por bucle infinito quema CPU: lo ves en top al instante. Este no gasta nada. El hilo está dormido en el kernel esperando datos que no van a venir. En la lista de procesos parece sano. En las métricas parece que "está tardando". La única forma de distinguir "colgado para siempre" de "va lento" es mirar el stack de hilos y ver el read() que no se mueve.
La solución no es matar mejor
El reflejo es intentar matar también a los nietos —un grupo de procesos, una sesión, el árbol entero—. A veces se puede, a veces no: un proceso puede escapar del grupo, un nieto puede sobrevivir a su padre a propósito.
Pero hay una salida más simple y más honesta, y sale de una sola pregunta: ¿qué proceso, concretamente, va a cerrar ese descriptor? Si la respuesta es "ninguno que yo controle", entonces esperar el EOF es esperar algo que no va a pasar. Y una espera por algo que no va a pasar no se arregla esperando mejor: se acota.
El arreglo fue poner un tiempo límite al propio drenaje. Tras matar el proceso, se espera el EOF unos segundos; si no llega, se abandona la lectura y se sigue. Lo que la herramienta alcanzó a escribir antes ya está capturado, así que no se pierde nada. Y un descriptor que un nieto mantiene abierto deja de importar: nadie lo espera indefinidamente.
La regla, más allá de las tuberías
Toda espera —un bucle que gira hasta que una condición se cumpla, un await sobre un descriptor— tiene detrás una pregunta única: ¿qué proceso va a hacer verdadera esa condición? Si no hay uno, la espera es imposible, y una espera imposible no da ningún error: se ve exactamente igual que un trabajo que tarda. Cero por ciento de CPU, sin avanzar, para siempre.
Antes de escribir la espera, nombra al proceso que la va a terminar. Si no puedes nombrarlo, no la escribas sin tope.
Xiliux