From 48a9aff129b9edc4aaaf9c68a3fdb0d9b39a720d Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 18 Sep 2026 16:31:33 +0000 Subject: [PATCH] ADR 0020: las fases corren con `sh -c` sin `-e`, y un fallo no final sella igual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sale de construir `zsh`. El agujero es estrictamente el de los comandos NO FINALES de una fase: el estado de salida de `sh -c` es el del último comando, así que `make … install install.info` podía morir con `makeinfo: not found` y el artefacto sellaba con `/usr/share/info` VACÍO adentro. Medido, no supuesto: 144 logs del worker, 88 sellados, 4 con fallo de alta señal. Clasificados uno por uno da UN hueco real confirmado (zsh, ya reparado en 3b36e9df), uno que NO lo es (python3: ahí takana SÍ abortó, porque el `make` era el último comando de su fase) y uno inconcluso (strace, en logs multi-receta donde el target que falla no se puede atribuir). Ese caso de python corrige mi propio encuadre inicial, que decía «las fases sellan con huecos silenciosos» a secas y era demasiado amplio. Queda escrito en el ADR porque la versión amplia manda a buscar el bug donde no está. Exposición medida: de 943 recetas, 142 tienen fase multi-comando escrita a mano. Las ~800 restantes NO están expuestas — usan las fases de `resolve_phases`, que encadena con `&&` y sí propaga. La decisión (`-ec` o no) queda ABIERTA a propósito: falta el único número que importa, que es cuántas de esas 142 dejan de sellar bajo `-ec`, y eso no se saca leyendo sino reconstruyendo. Cambiar el default a ciegas sobre 1115 sellados es el reflejo que este repo evita. --- docs/adr/0020-fases-de-build-sin-set-e.md | 113 ++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/adr/0020-fases-de-build-sin-set-e.md diff --git a/docs/adr/0020-fases-de-build-sin-set-e.md b/docs/adr/0020-fases-de-build-sin-set-e.md new file mode 100644 index 00000000..83e91439 --- /dev/null +++ b/docs/adr/0020-fases-de-build-sin-set-e.md @@ -0,0 +1,113 @@ +# ADR 0020 — Las fases de build no abortan a la primera: `sh -c` sin `-e` + +- **Estado:** PROPUESTO — el hallazgo está MEDIDO y un caso está reparado; la decisión (`-ec` sí o + no) queda abierta y depende de un experimento que todavía no se corrió (§Precondiciones). +- **Fecha:** 2026-09-18 +- **Frontera:** `crates/takana-build/src/sandbox.rs:332,491` (las dos invocaciones de `/bin/sh -c`), + `crates/takana-build/src/lib.rs:805` (`resolve_phases`, las fases generadas), y las 142 recetas + con fases escritas a mano. +- **Relacionado:** [CLAUDE.md regla 3](../../CLAUDE.md) (un vacío llega hasta el final diciendo que + todo fue bien), [ADR 0009](0009-content-addressed-code.md) (direccionamiento por contenido). + +## Contexto + +Las fases de una receta se ejecutan así, en los dos sitios donde se arma la orden del sandbox: + +```rust +args.push("/bin/sh".into()); +args.push("-c".into()); +``` + +**Sin `-e`.** La consecuencia es una regla de shell, no un bug: el estado de salida de un `sh -c` es +el del **ÚLTIMO comando**, y un fallo a mitad no aborta nada. Traducido a esta base de código: + +> Un comando que **no** sea el último de su fase puede fallar, y el artefacto sella igual. + +## Cómo se encontró (2026-09-18) + +Construyendo `zsh` — la única receta del corpus que faltaba por sellar. El build terminó en verde y +el artefacto pasó sus dos guardianes (estático, con `zsh/regex`, con `=~`). Pero el log traía: + +``` +/bin/sh: makeinfo: not found +make: *** [Makefile:255: install.info] Error 2 + ⇒ INFO takana_build: sealed path=./store/b96fb6e8…-zsh +``` + +El artefacto sellado llevaba **`/usr/share/info` VACÍO** adentro. `makeinfo` no está en el lab, así +que ese `make` fallaba en **todos** los builds de zsh, siempre, en silencio. + +## Lo que la medición SÍ dice, y lo que NO + +Se midieron 144 logs de build del worker; 88 llegaron a `sealed`. De ésos, **4** contienen líneas de +fallo de alta señal (`make: *** … Error N`, `/bin/sh: …: not found`). Clasificados uno por uno: + +| caso | veredicto | +|---|---| +| `zsh` → `install.info` | **HUECO REAL, CONFIRMADO.** `/usr/share/info` vacío en el artefacto. Reparado en `3b36e9df` | +| `python3` → `libpython3.12.a` | **NO es un hueco.** takana lo detectó y abortó: `Error: build phase falló (exit 2): make -j"$(nproc)"` — el `make` era el ÚLTIMO comando de su fase | +| `strace` → `all Error 2` (×2 logs) | **INCONCLUSO.** Son logs multi-receta (`reconstruccion-detalle.log` tiene 1194 `sealed`), así que el target que falla no se puede atribuir a un sellado concreto. No hay artefacto `-strace` en el store del worker para comprobarlo | + +⚠ **La primera versión de este hallazgo decía «las fases sellan con huecos silenciosos» a secas, y +era demasiado amplia.** El caso de python la corrige y conviene dejarlo escrito: **cuando el comando +que falla es el último, takana SÍ aborta.** El agujero es estrictamente el de los comandos **no +finales**. + +## La exposición, medida + +- **943 recetas.** 214 bloques de fase explícitos; de ésos **201 son multi-comando** y 13 de un solo + comando. **142 recetas** tienen al menos una fase multi-comando: ésa es la superficie expuesta. +- Las ~800 restantes **no están expuestas**: usan las fases que genera `resolve_phases`, que encadena + con `&&` (`autoreconf -fi && ./configure …`), y `&&` sí propaga el fallo. + +## Lo que hoy tapa el agujero, y por qué no alcanza + +En `zsh` el artefacto se salvó de verdad — pero no por el estado de salida, sino por **dos guardianes +que el autor de la receta escribió a mano**: uno mira el ELF por `INTERP`, otro pregunta al binario +si tiene sus módulos. Funcionaron. El problema es que son **específicos de zsh**: comprueban las dos +cosas que su autor supo prever. Una receta sin guardianes propios no tiene ninguna red, y hoy son la +minoría. + +Y hay una asimetría que conviene nombrar: los guardianes corren **después** del `make` precisamente +*porque* el shell no aborta. Si mañana se pone `-e`, esas recetas dejan de sellar — no porque el +artefacto sea peor, sino porque el fallo que hoy se tolera pasa a ser mortal. + +## Decisión — ABIERTA + +Las tres salidas, con lo que cada una cuesta: + +**a) `sh -ec` (abortar a la primera).** Correcto por defecto y alineado con la regla 3. Radio de +explosión: hasta 142 recetas, **muchas de ellas ya selladas y en uso**. Un build que hoy pasa +tolerando un fallo benigno (un `rm` de algo que no existe, un `install` opcional) pasaría a fallar. +No re-hashea nada —el shell no entra en `ArtifactHash`— pero puede dejar el corpus sin reconstruir. + +**b) `-e` por receta** (`strict_phases = true`), migrando de a poco. Sin big bang, pero deja el +default inseguro, que es lo que causó esto. + +**c) Dejarlo y exigir guardianes.** Es el statu quo, y el statu quo produjo un artefacto con un +directorio vacío que nadie vio durante un día. + +**Recomendación: (a), pero no antes de la medición de abajo.** Cambiar el default a ciegas sobre un +corpus de 1115 sellados es exactamente el reflejo que este repo evita. + +## Precondiciones — el experimento que falta + +Lo que hay hoy es un **cribado sobre logs**, no una medición del radio de explosión. El número que +falta es: *¿cuántas de las 142 recetas expuestas dejan de sellar bajo `-ec`?* Y no se puede sacar +leyendo: hay que reconstruirlas. + + # sobre una muestra de las 142, con el lock tomado una sola vez (CLAUDE.md regla 1) + flock -o work/.farm-build.lock bash -c 'for r in ; do takana --store ./store build "$r"; done' + +Con `-ec` puesto y contando los que caen. Una muestra de 20 alcanza para decidir; el corpus entero es +la validación. **Hasta que ese número exista, este ADR no se adopta.** + +## Consecuencias que se caen solas + +- Un `grep` de `Error [0-9]` sobre los logs de builds **sellados** es hoy una herramienta de auditoría + válida y barata. Cuatro de 88 no es ruido. +- Cualquier receta nueva con fase multi-comando nace expuesta. Mientras (a) no se adopte, lo honesto + es que el último comando de una fase sea el que verifica — que es, sin haberlo dicho nunca, lo que + `zsh` ya hacía con su bucle de guardianes. +- **Pedir algo que no puede funcionar y tolerar su fallo es peor que no pedirlo.** Es lo que se aplicó + en `3b36e9df`: `install.info` no se pide, en vez de pedirse y fallar en silencio.