#!/usr/bin/env python3 """ scripts/auditar-hilo-ui.py — cazador de trabajo bloqueante en el hilo de UI. Hace cumplir la regla de `COMPUTO-FUERA-DEL-HILO-UI.md`: ninguna de las funciones que la app corre en el hilo de UI —`init`, `update`, `view`, `view_overlay`— debe ejecutar trabajo pesado **síncrono**, o la ventana deja de repintar y aparece el "Not Responding". Por qué un script y no un grep: un `File::open` en el mismo *archivo* que un `update()` no prueba nada — puede vivir en otra función. Acá el cuerpo de cada una se extrae por **balanceo de llaves**, así que sólo cuenta lo que de verdad está adentro. Y si ese cuerpo despacha a un worker (`handle.spawn`, `spawn_periodic`, o un `std::thread::spawn` crudo) se informa como correcto, no como riesgo: es justamente el patrón que pide el documento. Tildar apps a mano no escala; esto lo vuelve repetible — se puede correr después de cada cambio y sobre apps nuevas, y sirve de gate en CI. Uso: ./scripts/auditar-hilo-ui.py # TODO el repo (default) ./scripts/auditar-hilo-ui.py widgets # un subárbol puntual Sale con código 1 si encontró algún caso sincrónico (sirve de gate). """ import re import sys import pathlib RAIZ = pathlib.Path(__file__).resolve().parent.parent # **El default es TODO el repo, no una lista.** # # Hasta 2026-08-05 acá había trece rutas escritas a mano —el checklist de # COMPUTO-FUERA-DEL-HILO-UI.md— y eso tenía un agujero silencioso: una app nueva # no entraba a la lista, así que nunca se auditaba, y nadie se enteraba porque el # script salía en verde. Auditando el repo entero apareció **suilad**, una app # que la lista no tenía: bloqueaba en `init` conectando al daemon de embeddings y # en `update` volcando un adjunto a disco. # # Auditar todo cuesta poco (segundos) y da poco ruido: sobre el repo completo # salen cinco marcas, de las que se descartan dos leyéndolas. Una lista que hay # que acordarse de actualizar es peor que un barrido con algún falso positivo. # # Se sigue pudiendo acotar a un subárbol pasándolo como argumento, que es lo # cómodo mientras se arregla algo puntual. RAIZ_POR_DEFECTO = ['.'] # **Las cuatro funciones que la app corre en el hilo de UI**, no dos. # # Hasta 2026-08-05 acá se auditaban sólo `update` e `init`, y eso dejaba afuera # a `view` — que es la peor de todas, porque corre en **cada cuadro** # (`eventloop/redraw.rs` la llama sin memoizar el resultado a nivel app). Un # `File::open` en `update` congela la ventana una vez; el mismo `File::open` en # `view` la congela sesenta veces por segundo. # # Al agregarlas el barrido salió en **cero hallazgos** sobre el repo entero, o # sea que hoy no arregla nada: es una guarda para que no aparezca el primero. Lo # que sí puede haber ahí —cómputo de CPU, que es justo el caso que originó esta # regla— ningún script lo va a ver; para eso está el vigía en vivo # (`llimphi-ui/src/eventloop/vigia.rs`), que desde el mismo día cronometra # también `view` y `view_overlay`. FUNCIONES_DEL_HILO_UI = ('update', 'init', 'view', 'view_overlay') # Trabajo que puede pasar de unos pocos ms. No pretende ser exhaustivo: es la # lista de lo que ya mordió. Agregar un caso = una línea. BLOQUEANTE = re.compile(r""" \bfs::(read|write|read_to_string|read_dir|copy|remove_dir|remove_file) | \bFile::(open|create) | \bblock_on\b | \breqwest::blocking\b | \.output\(\) | \bstd::thread::sleep\b """, re.X) # Lo que manda el trabajo afuera. `std::thread::spawn` cuenta: es un worker # igual, aunque no use el `Handle` de Llimphi. # # OJO — no alcanza con preguntar «¿este cuerpo contiene un spawn?»: un `update` # real es un `match` con decenas de arms, y basta que UNO despache para que el # cuerpo entero parezca sano mientras otro arm bloquea. (Pasó con `khipu-app`: # un arm hace `h.spawn(…)` y otro llama `rt.block_on(node.descubrir())` pelado.) # Por eso se calcula la **región** de cada spawn —de la llamada hasta su # paréntesis de cierre, que cubre tanto `spawn(move || { … })` como # `spawn(move || expr)`— y cada hallazgo se ubica dentro o fuera. SPAWN = re.compile(r'(?:handle|h|rt|self)?\.?\b(?:spawn|spawn_periodic|spawn_blocking)\s*\(') def regiones_worker(cuerpo: str): """[(inicio, fin)] de cada llamada a spawn(...), por balanceo de paréntesis.""" fuera = [] for m in SPAWN.finditer(cuerpo): i = m.end() - 1 # el '(' de la llamada prof, j = 0, i while j < len(cuerpo): if cuerpo[j] == '(': prof += 1 elif cuerpo[j] == ')': prof -= 1 if prof == 0: break j += 1 fuera.append((m.start(), j)) return fuera def enmascarar(texto: str) -> str: """Devuelve el texto con comentarios y literales de cadena reemplazados por espacios, conservando longitud y saltos de línea (así los offsets y los números de línea siguen valiendo sobre el original). Hace falta de verdad: sin esto el script se marca a sí mismo. Un comentario que dice «este `fs::copy` es a propósito» matcheaba como si fuera código, y encima corría el número de línea reportado hasta el comentario — dejando la marca de excepción, escrita más abajo, fuera de alcance. """ out = list(texto) i, n = 0, len(texto) while i < n: c = texto[i] if c == '/' and i + 1 < n and texto[i + 1] == '/': while i < n and texto[i] != '\n': out[i] = ' ' i += 1 elif c == '/' and i + 1 < n and texto[i + 1] == '*': while i < n and not (texto[i] == '*' and i + 1 < n and texto[i + 1] == '/'): if texto[i] != '\n': out[i] = ' ' i += 1 for _ in range(2): if i < n: out[i] = ' ' i += 1 elif c == '"': out[i] = ' ' i += 1 while i < n and texto[i] != '"': if texto[i] == '\\' and i + 1 < n: out[i] = ' ' i += 1 if i < n: if texto[i] != '\n': out[i] = ' ' i += 1 if i < n: out[i] = ' ' i += 1 else: i += 1 return ''.join(out) def cuerpos(texto: str, nombre: str): """Cuerpos de las `fn ` del archivo, por balanceo de llaves.""" for m in re.finditer(r'\bfn\s+' + nombre + r'\s*[(<]', texto): i = texto.find('{', m.end()) if i < 0: continue prof, j = 0, i while j < len(texto): if texto[j] == '{': prof += 1 elif texto[j] == '}': prof -= 1 if prof == 0: break j += 1 # La línea del `{`, **no la del `fn`**. Parece un detalle y no lo es: la # firma de un `update` real ocupa varias líneas, así que contar desde el # `fn` corría todos los números reportados hacia arriba por ese tanto. Se # notó porque rompía la marca de excepción: el script la busca en las # líneas de **arriba** del hallazgo, y con el número corrido la marca # —escrita justo encima de la llamada— quedaba abajo de la ventana y no # se veía. O sea que la vía para documentar una excepción no funcionaba. yield texto[i:j + 1], texto[:i].count('\n') + 1 # Marca de excepción. Se pone en un comentario **arriba** de la llamada, y # obliga a escribir el motivo al lado — un gate que se apaga sin explicar por # qué es un gate que en dos meses nadie sabe si sigue valiendo. # # // auditar-hilo-ui: intencional — persist() pisa esta misma ruta MARCA = 'auditar-hilo-ui: intencional' LINEAS_DE_GRACIA = 14 def exceptuado(texto_archivo: str, linea: int) -> bool: """¿Hay una marca de excepción en las líneas justo arriba de `linea`?""" lineas = texto_archivo.splitlines() desde = max(0, linea - 1 - LINEAS_DE_GRACIA) return any(MARCA in l for l in lineas[desde:linea]) def auditar(subarboles): sincronicos, con_worker = [], [] for app in subarboles: base = RAIZ / app if not base.is_dir(): print(f' (no existe: {app})', file=sys.stderr) continue for f in sorted(base.rglob('*.rs')): s = str(f) # Los examples son demos, no la app; los tests tampoco pintan. if '/target/' in s or '/examples/' in s or '/tests/' in s: continue try: original = f.read_text(encoding='utf-8', errors='ignore') except OSError: continue # El análisis corre sobre el texto SIN comentarios ni cadenas; la # marca de excepción, en cambio, se busca sobre el original (vive # justamente en un comentario). t = enmascarar(original) for fn in FUNCIONES_DEL_HILO_UI: for cuerpo, linea_fn in cuerpos(t, fn): workers = regiones_worker(cuerpo) base_ln = linea_fn # línea donde arranca la fn for m in BLOQUEANTE.finditer(cuerpo): pos = m.start() ln = base_ln + cuerpo[:pos].count('\n') if exceptuado(original, ln): continue dentro = any(a <= pos <= b for a, b in workers) # Relativa al repo cuando se puede; un subárbol de # afuera (probar contra una copia vieja, p.ej.) se # imprime tal cual en vez de reventar. try: ruta = str(f.relative_to(RAIZ)) except ValueError: ruta = str(f) reg = (ruta, ln, fn, m.group(0).strip()) (con_worker if dentro else sincronicos).append(reg) return sincronicos, con_worker def main(): subarboles = sys.argv[1:] or RAIZ_POR_DEFECTO sincronicos, con_worker = auditar(subarboles) if con_worker: print('== despachan a un worker (correcto, informativo) ==') for ruta, linea, fn, hit in con_worker: print(f' {ruta}:{linea} fn {fn}() · {hit}') print() if sincronicos: print('== SINCRONICO EN EL HILO DE UI ==') for ruta, linea, fn, hit in sincronicos: print(f' {ruta}:{linea} fn {fn}() · {hit}') print(f'\n{len(sincronicos)} caso(s). Patrón para arreglarlo: ' 'COMPUTO-FUERA-DEL-HILO-UI.md §«El patrón».') return 1 print("== ningún trabajo bloqueante dentro de update()/init()/view() ==") return 0 if __name__ == '__main__': sys.exit(main())