La ventana parecía congelada, pero el programa estuvo trabajando todo el rato. Simplemente no tenía forma de decirlo.
El encargo era coger un motor de cálculo de línea de comandos que funcionaba bien en manos de quien lo había escrito y convertirlo en una aplicación que otras personas pudieran instalar y usar en macOS, Windows y Linux. Le puse una interfaz gráfica encima que lanza el motor original como proceso hijo. Ahí salieron los problemas interesantes.
La demo de arriba recrea el primero. Pulsa Lanzar cálculo con los dos interruptores apagados: así se comportaba el programa original. Luego ve encendiéndolos uno a uno.
«Se ha colgado»
Al lanzar un cálculo largo, la ventana parecía congelarse. Había dos causas distintas, y desde fuera se veían exactamente igual.
El motor esperaba una respuesta por teclado. En la terminal era lo normal: hacía una pregunta, escribías s y seguía. Lanzado desde la interfaz, nadie podía contestar, así que se quedaba esperando para siempre. input() lee una línea de la entrada estándar y solo se rinde cuando llega al final del fichero, y entonces lanza EOFError. Una tubería en la que nadie escribe y que nadie cierra nunca llega ahí.
La salida se guardaba hasta el final. Esta es más traicionera. La documentación de Python lo dice sin rodeos: en modo interactivo, stdout va con buffer por líneas; si no, va con buffer por bloques, como un fichero normal. En una terminal, cada print() aparece al momento. Cuando el proceso padre lee stdout por una tubería, ya no es interactivo, así que las líneas se acumulan en memoria y solo llegan a la interfaz cuando se llena el buffer o termina el proceso. El motor estuvo escribiendo su progreso todo el tiempo. La ventana, sencillamente, no lo recibía.
Eso es lo que enseña la columna central de la demo: con el buffer activo, el hijo escribe cada paso, los bytes se van acumulando y la ventana lo recibe todo de golpe al final.
Qué cambié
Tres cosas, todas pequeñas:
- Opciones como argumentos en lugar de preguntas. La interfaz pasa por línea de comandos todo lo que antes preguntaba el motor, así que nunca llega al
input(). PYTHONUNBUFFERED=1en el entorno del hijo. Darle cualquier valor no vacío equivale a ejecutar Python con-u, que obliga a questdoutystderrvayan sin buffer.- Progreso paso a paso con tiempo estimado. En cuanto las líneas llegan según se escriben, la interfaz puede mostrar una barra de progreso de verdad.
Esta es la versión mínima de los dos lados. El motor:
import argparse, time
parser = argparse.ArgumentParser()
parser.add_argument("--steps", type=int, default=8)
parser.add_argument("--defaults", action="store_true")
args = parser.parse_args()
start = time.monotonic()
for i in range(1, args.steps + 1):
time.sleep(0.2) # aquí va el trabajo de verdad
elapsed = time.monotonic() - start
eta = elapsed / i * (args.steps - i)
print(f"step {i}/{args.steps} · ETA {eta:.1f} s")
Y el lado que lo lanza:
import os, subprocess, sys, time
env = {**os.environ, "PYTHONUNBUFFERED": "1"}
proc = subprocess.Popen(
[sys.executable, "engine.py", "--defaults", "--steps", "4"],
stdin=subprocess.DEVNULL, # nadie va a escribir: input() lanza EOFError en vez de esperar
stdout=subprocess.PIPE,
text=True,
env=env,
)
t0 = time.monotonic()
for line in proc.stdout: # cada línea llega en cuanto el hijo la imprime
print(f"{time.monotonic() - t0:.2f}s {line.rstrip()}")
proc.wait()
Lo ejecuté de las dos formas. Con PYTHONUNBUFFERED=1, las cuatro líneas llegaron a los 0,23, 0,43, 0,63 y 0,83 segundos. Sin él, las cuatro llegaron juntas a los 0,82 segundos. El mismo trabajo y el mismo tiempo total; solo cambia la experiencia.
stdin=subprocess.DEVNULL es una red de seguridad barata además de los argumentos: si queda algún input() olvidado, llega al final del fichero y falla en el acto con EOFError en lugar de quedarse colgado en silencio. Mejor un fallo ruidoso que una espera eterna.
El cálculo no iba más rápido. Simplemente dejó de dar miedo.
Un resultado desplazado un período entero
El segundo problema era peor, porque no parecía un problema. Con algunos ficheros de datos, una fecha salía adelantada exactamente un período. El programa no fallaba: daba un número que parecía bueno.
La causa era que tomaba como referencia un punto auxiliar del fichero de datos, no la última observación real. Excluí esos puntos y comprobé que el caso se corregía sin cambiar los resultados de los demás ficheros de referencia.
Este es el tipo de error que más me preocupa, porque no avisa. Por eso compensa tener casos de prueba con resultados conocidos y volver a pasarlos después de cada cambio.
«La aplicación está dañada»
En macOS, la aplicación recién descargada daba ese mensaje. Lo resolví firmándola de forma ad hoc durante la compilación automática. También dejé compilaciones automáticas para los tres sistemas y mensajes claros cuando se carga un fichero equivocado, en lugar de un error incomprensible.
Si te pasa lo mismo
- ¿Pregunta algo por teclado? Busca
input(en el motor. Convierte cada pregunta en un argumento y lanza el hijo constdin=subprocess.DEVNULLpara que lo que quede falle rápido. - ¿El progreso solo aparece al final? Pon
PYTHONUNBUFFERED=1en elenvdel hijo, ejecútalo conpython -uo pasaflush=Truea losprint()que te importen. - ¿
stderrse comporta distinto questdout? Desde Python 3.9,stderrno interactivo va con buffer por líneas, así que los avisos pueden aparecer mientras la salida normal no. Esa diferencia es una pista muy clara. - No dejes de leer la tubería. La documentación de
subprocessavisa de quewait()puede bloquearse constdout=PIPEsi el hijo llena el buffer de la tubería del sistema. Léela según llega y, en una interfaz gráfica, hazlo fuera del hilo de la interfaz para que la ventana no se congele de verdad. - Guarda casos de referencia con resultados conocidos. Los errores que no rompen nada son los que se cuelan.
Qué vigilar
- Motores que no son Python.
PYTHONUNBUFFEREDes un ajuste de Python. Si el motor está escrito en otra cosa, mira cómo ese lenguaje gestiona el buffer de la salida a una tubería antes de dar por hecho que sirve el mismo arreglo. - Líneas a medias. Una pregunta como
input("¿Continuar? ")no acaba en salto de línea, así que un padre que lee línea a línea no la verá ni siquiera con-u. Lo comprobé: la pregunta no aparece mientras el hijo espera. - Hijos muy habladores. Para unas cuantas líneas de progreso, quitar el buffer no cuesta nada apreciable. Si el motor suelta logs enormes, yo dejaría el buffer y usaría
flush=Truesolo en las líneas de progreso.
Si tienes un programa que solo sabe usar quien lo escribió, escríbeme.
Nota escrita con ayuda de IA a partir de un trabajo real mío.