gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main scripts/auditar-hilo-ui.py Python · 259 líneas · 10.7 KB Historia Quién tocó qué crudo

docs: la regla del hilo de UI llega entera — el vigía, el auditor y la categoría que no va a un worker 168cd1875 · Sergio ·

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())