"""Tareas en paralelo: cada orden corre por su cuenta y se puede parar. Hasta ahora una orden era una llamada a `ejecuta()`: un `subprocess.run` con veinte segundos de limite que bloqueaba el hilo hasta terminar. Servia porque todos los atajos eran instantaneos, pero deja fuera justo lo que uno querria mandar y olvidarse: un escaneo, una busqueda por todo el disco, una descarga. Y no habia forma de arrepentirse a mitad. Aqui cada orden es una Tarea con nombre, estado y un proceso propio del que se guarda el grupo, para poder matarlo entero. Tres cosas que costaron decidir: - **Se mata el grupo, no el proceso.** El comando real es `bash -lc "..."`, asi que matar el bash deja vivos a sus hijos —el `find`, el `nmap`— corriendo y comiendo disco con la tarjeta ya cerrada. Con `start_new_session=True` el proceso estrena grupo y `killpg` se los lleva a todos. - **Fuera del sandbox tambien vale.** Comprobado: matar el `flatpak-spawn` intermediario propaga la señal al proceso del host (2 procesos vivos -> 0). No hace falta perseguir el PID del otro lado. - **La salida se lee segun sale, no al final.** Con `run()` no hay nada que enseñar hasta que termina, y una tarjeta de un escaneo de tres minutos que esta en blanco los tres minutos no informa de nada. Se lee linea a linea y se guardan las ultimas, que es lo que cabe en la tarjeta. """ import os import shlex import signal import subprocess import threading import time import urllib.parse # Lo que se guarda de la salida para enseñar en la tarjeta y para la plantilla # de respuesta. Una tarjeta pequeña no da para mas, y guardar la salida entera # de un `find /` es meterse megas en memoria por nada. LINEAS_VISTA = 6 SALIDA_MAXIMA = 600 # Con boton de parar, el limite deja de ser una proteccion y pasa a ser un # estorbo: veinte segundos matan cualquier escaneo util. Se deja un tope alto # como red para lo que se cuelgue de verdad y no vuelva nunca. LIMITE_LARGO = 900 # 15 minutos # Cada cuanto se avisa al HUD mientras sale texto. Sin freno, un comando # hablador manda cientos de mensajes por segundo al WebView y la interfaz se # atasca dibujando texto que nadie llega a leer. CADENCIA_AVISO = 0.25 CORRIENDO, HECHA, PARADA, FALLO, CADUCADA = ( "corriendo", "hecha", "parada", "fallo", "caducada") class Tarea: """Una orden en marcha. Solo datos; quien la mueve es el gestor.""" def __init__(self, ident, titulo, accion_id, comando): self.id = ident self.titulo = titulo self.accion_id = accion_id self.comando = comando # La accion de origen viaja con la tarea (y no en un diccionario # aparte del panel) porque una tarea sin comando termina DENTRO de # lanza(), antes de que quien la lanzo tenga el id para apuntarla: el # aviso de "terminada" llegaria a un registro todavia vacio. self.accion = None self.argumento = "" self.estado = CORRIENDO self.inicio = time.monotonic() self.fin = None self.lineas = [] # las ultimas LINEAS_VISTA de salida self.salida = "" # la salida acumulada, recortada self.codigo = None self._proc = None @property def segundos(self) -> float: return (self.fin or time.monotonic()) - self.inicio @property def viva(self) -> bool: return self.estado == CORRIENDO def resumen(self) -> dict: """Lo que viaja al HUD. Sin objetos: esto acaba en un json.dumps.""" return { "id": self.id, "titulo": self.titulo, "accion": self.accion_id, "estado": self.estado, "segundos": round(self.segundos, 1), "lineas": self.lineas[-LINEAS_VISTA:], "codigo": self.codigo, } class GestorTareas: """Lanza, vigila y para tareas. Un unico sitio donde vive el registro. al_cambiar(tarea) se llama desde el hilo de la tarea cada vez que hay algo nuevo que enseñar. Quien lo reciba se encarga de saltar al hilo de la interfaz: aqui no se sabe nada de GTK a proposito, para poder probarlo sin levantar la aplicacion. """ def __init__(self, al_cambiar=None, prefijo_host=()): self._al_cambiar = al_cambiar or (lambda tarea: None) self._prefijo_host = _con_watch_bus(prefijo_host) self._tareas = {} self._siguiente = 0 self._cerrojo = threading.Lock() # -------------------------------------------------------------- registro def _nueva(self, titulo, accion_id, comando) -> Tarea: with self._cerrojo: self._siguiente += 1 ident = f"t{self._siguiente}" tarea = Tarea(ident, titulo, accion_id, comando) self._tareas[ident] = tarea return tarea def get(self, ident): with self._cerrojo: return self._tareas.get(ident) def activas(self) -> list: with self._cerrojo: return [t for t in self._tareas.values() if t.viva] def todas(self) -> list: with self._cerrojo: return list(self._tareas.values()) def _avisa(self, tarea): try: self._al_cambiar(tarea) except Exception as e: # el HUD no tumba la tarea print(f"JARVIS: aviso de tarea fallido: {e}") # ---------------------------------------------------------------- lanzar def lanza(self, accion, argumento="", titulo=None) -> Tarea: """Arranca la accion en su propio proceso y devuelve la tarea ya viva.""" comando = accion.comando or "" if comando: comando = comando.replace("{argumento}", shlex.quote(argumento)) comando = comando.replace( "{argumento_url}", shlex.quote(urllib.parse.quote_plus(argumento))) tarea = self._nueva(titulo or _titulo_de(accion, argumento), accion.id, comando) tarea.accion = accion tarea.argumento = argumento self._avisa(tarea) if not comando: # Acciones que solo hablan (no tienen comando): nacen terminadas. self.termina(tarea.id, HECHA, "") return tarea orden = ["bash", "-lc", comando] if accion.host: orden = self._prefijo_host + orden threading.Thread(target=self._corre, args=(tarea, orden), daemon=True).start() return tarea def adopta(self, titulo, accion_id="") -> Tarea: """Una tarjeta para trabajo que se ejecuta fuera del gestor. Lo que pasa por el dialogo de root corre por su camino (GestorRoot, con su contraseña), pero se merece tarjeta igual: si no, pedir algo con sudo hace desaparecer la señal de que hay algo en marcha. """ tarea = self._nueva(titulo, accion_id, "") self._avisa(tarea) return tarea def _corre(self, tarea, orden): try: # start_new_session: el proceso estrena grupo, para poder matar el # arbol entero y no solo el bash de arriba. proc = subprocess.Popen( orden, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace", start_new_session=True) except OSError as e: self.termina(tarea.id, FALLO, f"No he podido ejecutarlo: {e}") return tarea._proc = proc limite = threading.Timer(LIMITE_LARGO, self._caduca, args=(tarea.id,)) limite.daemon = True limite.start() ultimo_aviso = 0.0 acumulado = [] try: for linea in proc.stdout: linea = linea.rstrip("\n") if not linea.strip(): continue acumulado.append(linea) tarea.lineas = acumulado[-LINEAS_VISTA:] ahora = time.monotonic() if ahora - ultimo_aviso >= CADENCIA_AVISO: ultimo_aviso = ahora self._avisa(tarea) except Exception as e: print(f"JARVIS: leyendo la salida de {tarea.id}: {e}") finally: limite.cancel() codigo = proc.wait() salida = "\n".join(acumulado).strip() if tarea.estado == PARADA: # la paro alguien: no se toca self._avisa(tarea) return if tarea.estado == CADUCADA: self._avisa(tarea) return self.termina(tarea.id, HECHA if codigo == 0 else FALLO, salida, codigo) # ------------------------------------------------------------- terminar def termina(self, ident, estado, salida="", codigo=None): tarea = self.get(ident) if tarea is None: return tarea.estado = estado tarea.fin = time.monotonic() tarea.codigo = codigo if salida: tarea.salida = salida[:SALIDA_MAXIMA] if not tarea.lineas: tarea.lineas = salida.splitlines()[-LINEAS_VISTA:] else: tarea.salida = "\n".join(tarea.lineas)[:SALIDA_MAXIMA] self._avisa(tarea) def para(self, ident) -> bool: """Corta la tarea. Primero por las buenas, luego a la fuerza.""" tarea = self.get(ident) if tarea is None or not tarea.viva: return False tarea.estado = PARADA proc = tarea._proc if proc is not None and proc.poll() is None: _mata_grupo(proc) tarea.fin = time.monotonic() self._avisa(tarea) return True def _caduca(self, ident): tarea = self.get(ident) if tarea is None or not tarea.viva: return tarea.estado = CADUCADA if tarea._proc is not None and tarea._proc.poll() is None: _mata_grupo(tarea._proc) tarea.fin = time.monotonic() self._avisa(tarea) def cierra(self, ident) -> bool: """Quita la tarjeta. Si seguia viva, la para antes: cerrar es cerrar.""" tarea = self.get(ident) if tarea is None: return False if tarea.viva: self.para(ident) with self._cerrojo: self._tareas.pop(ident, None) return True def para_todas(self): for tarea in self.activas(): self.para(tarea.id) def _con_watch_bus(prefijo) -> list: """Que lo lanzado fuera del sandbox no sobreviva a la aplicacion. Una tarea larga corre en el host por flatpak-spawn, y ahi el proceso ya no es hijo de nadie que se entere de que la app se ha cerrado: un escaneo lanzado y olvidado seguiria comiendo maquina despues de cerrar la ventana, sin ventana desde la que pararlo. `--watch-bus` ata su vida a la conexion de bus de quien lo lanzo, que es justo lo que se quiere. """ prefijo = list(prefijo) if not prefijo: return prefijo if any("flatpak-spawn" in p for p in prefijo) and "--watch-bus" not in prefijo: # detras del propio flatpak-spawn, antes de sus argumentos for i, p in enumerate(prefijo): if "flatpak-spawn" in p: prefijo.insert(i + 1, "--watch-bus") break return prefijo def _mata_grupo(proc): """SIGTERM al grupo, y SIGKILL a los tres segundos si sigue ahi. Se da margen a proposito: un `wget` a medias cierra el fichero y un `nmap` escupe lo que llevaba. Matar a lo bruto de entrada deja restos. """ try: grupo = os.getpgid(proc.pid) except OSError: return try: os.killpg(grupo, signal.SIGTERM) except OSError: return def remata(): if proc.poll() is None: try: os.killpg(grupo, signal.SIGKILL) except OSError: pass t = threading.Timer(3.0, remata) t.daemon = True t.start() def _titulo_de(accion, argumento) -> str: """El rotulo de la tarjeta: corto, y con el argumento si lo hay. El id de la accion con guiones bajos ("buscar_fichero") es de programador; en la tarjeta se lee "buscar fichero", y con lo que se pidio detras, que es lo que distingue dos busquedas a la vez. """ base = (accion.id or "accion").replace("_", " ") if argumento: arg = argumento.strip() if len(arg) > 22: arg = arg[:21] + "…" return f"{base}: {arg}" return base