tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1#!/usr/bin/env python3
2"""
3scripts/auditar-hilo-ui.py — cazador de trabajo bloqueante en el hilo de UI.
4
5Hace cumplir la regla de `COMPUTO-FUERA-DEL-HILO-UI.md`:
6ninguna de las funciones que la app corre en el hilo de UI —`init`, `update`,
7`view`, `view_overlay`— debe ejecutar trabajo pesado **síncrono**, o la ventana
8deja de repintar y aparece el "Not Responding".
9
10Por qué un script y no un grep: un `File::open` en el mismo *archivo* que un
11`update()` no prueba nada — puede vivir en otra función. Acá el cuerpo de cada
12una se extrae por **balanceo de llaves**, así que sólo cuenta
13lo que de verdad está adentro. Y si ese cuerpo despacha a un worker
14(`handle.spawn`, `spawn_periodic`, o un `std::thread::spawn` crudo) se informa
15como correcto, no como riesgo: es justamente el patrón que pide el documento.
16
17Tildar apps a mano no escala; esto lo vuelve repetible — se puede correr
18después de cada cambio y sobre apps nuevas, y sirve de gate en CI.
19
20Uso:
21 ./scripts/auditar-hilo-ui.py # TODO el repo (default)
22 ./scripts/auditar-hilo-ui.py widgets # un subárbol puntual
23
24Sale con código 1 si encontró algún caso sincrónico (sirve de gate).
25"""
26import re
27import sys
28import pathlib
29
30RAIZ = pathlib.Path(__file__).resolve().parent.parent
31
32# **El default es TODO el repo, no una lista.**
33#
34# Hasta 2026-08-05 acá había trece rutas escritas a mano —el checklist de
35# COMPUTO-FUERA-DEL-HILO-UI.md— y eso tenía un agujero silencioso: una app nueva
36# no entraba a la lista, así que nunca se auditaba, y nadie se enteraba porque el
37# script salía en verde. Auditando el repo entero apareció **suilad**, una app
38# que la lista no tenía: bloqueaba en `init` conectando al daemon de embeddings y
39# en `update` volcando un adjunto a disco.
40#
41# Auditar todo cuesta poco (segundos) y da poco ruido: sobre el repo completo
42# salen cinco marcas, de las que se descartan dos leyéndolas. Una lista que hay
43# que acordarse de actualizar es peor que un barrido con algún falso positivo.
44#
45# Se sigue pudiendo acotar a un subárbol pasándolo como argumento, que es lo
46# cómodo mientras se arregla algo puntual.
47RAIZ_POR_DEFECTO = ['.']
48
49# **Las cuatro funciones que la app corre en el hilo de UI**, no dos.
50#
51# Hasta 2026-08-05 acá se auditaban sólo `update` e `init`, y eso dejaba afuera
52# a `view` — que es la peor de todas, porque corre en **cada cuadro**
53# (`eventloop/redraw.rs` la llama sin memoizar el resultado a nivel app). Un
54# `File::open` en `update` congela la ventana una vez; el mismo `File::open` en
55# `view` la congela sesenta veces por segundo.
56#
57# Al agregarlas el barrido salió en **cero hallazgos** sobre el repo entero, o
58# sea que hoy no arregla nada: es una guarda para que no aparezca el primero. Lo
59# que sí puede haber ahí —cómputo de CPU, que es justo el caso que originó esta
60# regla— ningún script lo va a ver; para eso está el vigía en vivo
61# (`llimphi-ui/src/eventloop/vigia.rs`), que desde el mismo día cronometra
62# también `view` y `view_overlay`.
63FUNCIONES_DEL_HILO_UI = ('update', 'init', 'view', 'view_overlay')
64
65# Trabajo que puede pasar de unos pocos ms. No pretende ser exhaustivo: es la
66# lista de lo que ya mordió. Agregar un caso = una línea.
67BLOQUEANTE = re.compile(r"""
68 \bfs::(read|write|read_to_string|read_dir|copy|remove_dir|remove_file)
69 | \bFile::(open|create)
70 | \bblock_on\b
71 | \breqwest::blocking\b
72 | \.output\(\)
73 | \bstd::thread::sleep\b
74""", re.X)
75
76# Lo que manda el trabajo afuera. `std::thread::spawn` cuenta: es un worker
77# igual, aunque no use el `Handle` de Llimphi.
78#
79# OJO — no alcanza con preguntar «¿este cuerpo contiene un spawn?»: un `update`
80# real es un `match` con decenas de arms, y basta que UNO despache para que el
81# cuerpo entero parezca sano mientras otro arm bloquea. (Pasó con `khipu-app`:
82# un arm hace `h.spawn(…)` y otro llama `rt.block_on(node.descubrir())` pelado.)
83# Por eso se calcula la **región** de cada spawn —de la llamada hasta su
84# paréntesis de cierre, que cubre tanto `spawn(move || { … })` como
85# `spawn(move || expr)`— y cada hallazgo se ubica dentro o fuera.
86SPAWN = re.compile(r'(?:handle|h|rt|self)?\.?\b(?:spawn|spawn_periodic|spawn_blocking)\s*\(')
87
88
89def regiones_worker(cuerpo: str):
90 """[(inicio, fin)] de cada llamada a spawn(...), por balanceo de paréntesis."""
91 fuera = []
92 for m in SPAWN.finditer(cuerpo):
93 i = m.end() - 1 # el '(' de la llamada
94 prof, j = 0, i
95 while j < len(cuerpo):
96 if cuerpo[j] == '(':
97 prof += 1
98 elif cuerpo[j] == ')':
99 prof -= 1
100 if prof == 0:
101 break
102 j += 1
103 fuera.append((m.start(), j))
104 return fuera
105
106
107def enmascarar(texto: str) -> str:
108 """Devuelve el texto con comentarios y literales de cadena reemplazados por
109 espacios, conservando longitud y saltos de línea (así los offsets y los
110 números de línea siguen valiendo sobre el original).
111
112 Hace falta de verdad: sin esto el script se marca a sí mismo. Un comentario
113 que dice «este `fs::copy` es a propósito» matcheaba como si fuera código, y
114 encima corría el número de línea reportado hasta el comentario — dejando la
115 marca de excepción, escrita más abajo, fuera de alcance.
116 """
117 out = list(texto)
118 i, n = 0, len(texto)
119 while i < n:
120 c = texto[i]
121 if c == '/' and i + 1 < n and texto[i + 1] == '/':
122 while i < n and texto[i] != '\n':
123 out[i] = ' '
124 i += 1
125 elif c == '/' and i + 1 < n and texto[i + 1] == '*':
126 while i < n and not (texto[i] == '*' and i + 1 < n and texto[i + 1] == '/'):
127 if texto[i] != '\n':
128 out[i] = ' '
129 i += 1
130 for _ in range(2):
131 if i < n:
132 out[i] = ' '
133 i += 1
134 elif c == '"':
135 out[i] = ' '
136 i += 1
137 while i < n and texto[i] != '"':
138 if texto[i] == '\\' and i + 1 < n:
139 out[i] = ' '
140 i += 1
141 if i < n:
142 if texto[i] != '\n':
143 out[i] = ' '
144 i += 1
145 if i < n:
146 out[i] = ' '
147 i += 1
148 else:
149 i += 1
150 return ''.join(out)
151
152
153def cuerpos(texto: str, nombre: str):
154 """Cuerpos de las `fn <nombre>` del archivo, por balanceo de llaves."""
155 for m in re.finditer(r'\bfn\s+' + nombre + r'\s*[(<]', texto):
156 i = texto.find('{', m.end())
157 if i < 0:
158 continue
159 prof, j = 0, i
160 while j < len(texto):
161 if texto[j] == '{':
162 prof += 1
163 elif texto[j] == '}':
164 prof -= 1
165 if prof == 0:
166 break
167 j += 1
168 # La línea del `{`, **no la del `fn`**. Parece un detalle y no lo es: la
169 # firma de un `update` real ocupa varias líneas, así que contar desde el
170 # `fn` corría todos los números reportados hacia arriba por ese tanto. Se
171 # notó porque rompía la marca de excepción: el script la busca en las
172 # líneas de **arriba** del hallazgo, y con el número corrido la marca
173 # —escrita justo encima de la llamada— quedaba abajo de la ventana y no
174 # se veía. O sea que la vía para documentar una excepción no funcionaba.
175 yield texto[i:j + 1], texto[:i].count('\n') + 1
176
177
178# Marca de excepción. Se pone en un comentario **arriba** de la llamada, y
179# obliga a escribir el motivo al lado — un gate que se apaga sin explicar por
180# qué es un gate que en dos meses nadie sabe si sigue valiendo.
181#
182# // auditar-hilo-ui: intencional — persist() pisa esta misma ruta
183MARCA = 'auditar-hilo-ui: intencional'
184LINEAS_DE_GRACIA = 14
185
186
187def exceptuado(texto_archivo: str, linea: int) -> bool:
188 """¿Hay una marca de excepción en las líneas justo arriba de `linea`?"""
189 lineas = texto_archivo.splitlines()
190 desde = max(0, linea - 1 - LINEAS_DE_GRACIA)
191 return any(MARCA in l for l in lineas[desde:linea])
192
193
194def auditar(subarboles):
195 sincronicos, con_worker = [], []
196 for app in subarboles:
197 base = RAIZ / app
198 if not base.is_dir():
199 print(f' (no existe: {app})', file=sys.stderr)
200 continue
201 for f in sorted(base.rglob('*.rs')):
202 s = str(f)
203 # Los examples son demos, no la app; los tests tampoco pintan.
204 if '/target/' in s or '/examples/' in s or '/tests/' in s:
205 continue
206 try:
207 original = f.read_text(encoding='utf-8', errors='ignore')
208 except OSError:
209 continue
210 # El análisis corre sobre el texto SIN comentarios ni cadenas; la
211 # marca de excepción, en cambio, se busca sobre el original (vive
212 # justamente en un comentario).
213 t = enmascarar(original)
214 for fn in FUNCIONES_DEL_HILO_UI:
215 for cuerpo, linea_fn in cuerpos(t, fn):
216 workers = regiones_worker(cuerpo)
217 base_ln = linea_fn # línea donde arranca la fn
218 for m in BLOQUEANTE.finditer(cuerpo):
219 pos = m.start()
220 ln = base_ln + cuerpo[:pos].count('\n')
221 if exceptuado(original, ln):
222 continue
223 dentro = any(a <= pos <= b for a, b in workers)
224 # Relativa al repo cuando se puede; un subárbol de
225 # afuera (probar contra una copia vieja, p.ej.) se
226 # imprime tal cual en vez de reventar.
227 try:
228 ruta = str(f.relative_to(RAIZ))
229 except ValueError:
230 ruta = str(f)
231 reg = (ruta, ln, fn, m.group(0).strip())
232 (con_worker if dentro else sincronicos).append(reg)
233 return sincronicos, con_worker
234
235
236def main():
237 subarboles = sys.argv[1:] or RAIZ_POR_DEFECTO
238 sincronicos, con_worker = auditar(subarboles)
239
240 if con_worker:
241 print('== despachan a un worker (correcto, informativo) ==')
242 for ruta, linea, fn, hit in con_worker:
243 print(f' {ruta}:{linea} fn {fn}() · {hit}')
244 print()
245
246 if sincronicos:
247 print('== SINCRONICO EN EL HILO DE UI ==')
248 for ruta, linea, fn, hit in sincronicos:
249 print(f' {ruta}:{linea} fn {fn}() · {hit}')
250 print(f'\n{len(sincronicos)} caso(s). Patrón para arreglarlo: '
251 'COMPUTO-FUERA-DEL-HILO-UI.md §«El patrón».')
252 return 1
253
254 print("== ningún trabajo bloqueante dentro de update()/init()/view() ==")
255 return 0
256
257
258if __name__ == '__main__':
259 sys.exit(main())