ADR 0020: las fases corren con sh -c sin -e, y un fallo no final sella igual
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.
This commit is contained in:
@@ -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 <muestra>; 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.
|
||||
Reference in New Issue
Block a user