1# ADR 0020 — Las fases de build no abortan a la primera: `sh -c` sin `-e`
2
3- **Estado:** PROPUESTO — hallazgo MEDIDO, un caso reparado y el experimento CORRIDO sobre muestra
4 ligera (§El experimento). Falta la cola pesada antes de adoptar (§Lo que queda por medir).
5- **Fecha:** 2026-09-18
6- **Frontera:** `crates/takana-build/src/sandbox.rs:332,491` (las dos invocaciones de `/bin/sh -c`),
7 `crates/takana-build/src/lib.rs:805` (`resolve_phases`, las fases generadas), y las 142 recetas
8 con fases escritas a mano.
9- **Relacionado:** [CLAUDE.md regla 3](../../CLAUDE.md) (un vacío llega hasta el final diciendo que
10 todo fue bien), [ADR 0009](0009-content-addressed-code.md) (direccionamiento por contenido).
11
12## Contexto
13
14Las fases de una receta se ejecutan así, en los dos sitios donde se arma la orden del sandbox:
15
16```rust
17args.push("/bin/sh".into());
18args.push("-c".into());
19```
20
21**Sin `-e`.** La consecuencia es una regla de shell, no un bug: el estado de salida de un `sh -c` es
22el del **ÚLTIMO comando**, y un fallo a mitad no aborta nada. Traducido a esta base de código:
23
24> Un comando que **no** sea el último de su fase puede fallar, y el artefacto sella igual.
25
26## Cómo se encontró (2026-09-18)
27
28Construyendo `zsh` — la única receta del corpus que faltaba por sellar. El build terminó en verde y
29el artefacto pasó sus dos guardianes (estático, con `zsh/regex`, con `=~`). Pero el log traía:
30
31```
32/bin/sh: makeinfo: not found
33make: *** [Makefile:255: install.info] Error 2
34 ⇒ INFO takana_build: sealed path=./store/b96fb6e8…-zsh
35```
36
37El artefacto sellado llevaba **`/usr/share/info` VACÍO** adentro. `makeinfo` no está en el lab, así
38que ese `make` fallaba en **todos** los builds de zsh, siempre, en silencio.
39
40## Lo que la medición SÍ dice, y lo que NO
41
42Se midieron 144 logs de build del worker; 88 llegaron a `sealed`. De ésos, **4** contienen líneas de
43fallo de alta señal (`make: *** … Error N`, `/bin/sh: …: not found`). Clasificados uno por uno:
44
45| caso | veredicto |
46|---|---|
47| `zsh` → `install.info` | **HUECO REAL, CONFIRMADO.** `/usr/share/info` vacío en el artefacto. Reparado en `3b36e9df` |
48| `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 |
49| `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 |
50
51⚠ **La primera versión de este hallazgo decía «las fases sellan con huecos silenciosos» a secas, y
52era demasiado amplia.** El caso de python la corrige y conviene dejarlo escrito: **cuando el comando
53que falla es el último, takana SÍ aborta.** El agujero es estrictamente el de los comandos **no
54finales**.
55
56## La exposición, medida
57
58- **943 recetas.** 214 bloques de fase explícitos; de ésos **201 son multi-comando** y 13 de un solo
59 comando. **142 recetas** tienen al menos una fase multi-comando: ésa es la superficie expuesta.
60- Las ~800 restantes **no están expuestas**: usan las fases que genera `resolve_phases`, que encadena
61 con `&&` (`autoreconf -fi && ./configure …`), y `&&` sí propaga el fallo.
62
63**Y el corpus YA resolvió esto a mano, en un tercio de los casos.** 53 de esas 142 recetas abren
64alguna fase con un `set -e` propio — `wtype` es una de ellas. Eso son dos datos, no uno:
65
661. **El default está mal y los autores ya lo sabían.** Nadie escribe `set -e` en 53 recetas por gusto;
67 se escribe después de que algo selle mal. Es la misma lección que este ADR, pagada de a una.
682. **Achica el radio de explosión.** Esas 53 ya corren bajo `set -e` y no romperían con `-ec`: la
69 migración no son 142 recetas, son **89** — las expuestas que todavía no se defienden solas.
70
71## Lo que hoy tapa el agujero, y por qué no alcanza
72
73En `zsh` el artefacto se salvó de verdad — pero no por el estado de salida, sino por **dos guardianes
74que el autor de la receta escribió a mano**: uno mira el ELF por `INTERP`, otro pregunta al binario
75si tiene sus módulos. Funcionaron. El problema es que son **específicos de zsh**: comprueban las dos
76cosas que su autor supo prever. Una receta sin guardianes propios no tiene ninguna red, y hoy son la
77minoría.
78
79Y hay una asimetría que conviene nombrar: los guardianes corren **después** del `make` precisamente
80*porque* el shell no aborta. Si mañana se pone `-e`, esas recetas dejan de sellar — no porque el
81artefacto sea peor, sino porque el fallo que hoy se tolera pasa a ser mortal.
82
83## Decisión — recomendada, pendiente de la cola pesada
84
85Las tres salidas, con lo que cada una cuesta:
86
87**a) `sh -ec` (abortar a la primera).** Correcto por defecto y alineado con la regla 3. Radio de
88explosión: hasta 142 recetas, **muchas de ellas ya selladas y en uso**. Un build que hoy pasa
89tolerando un fallo benigno (un `rm` de algo que no existe, un `install` opcional) pasaría a fallar.
90No re-hashea nada —el shell no entra en `ArtifactHash`— pero puede dejar el corpus sin reconstruir.
91
92**b) `-e` por receta** (`strict_phases = true`), migrando de a poco. Sin big bang, pero deja el
93default inseguro, que es lo que causó esto.
94
95**c) Dejarlo y exigir guardianes.** Es el statu quo, y el statu quo produjo un artefacto con un
96directorio vacío que nadie vio durante un día.
97
98**Recomendación: (a), y ya no a ciegas.** El experimento de abajo pone el coste en ~8% de las
99expuestas — unas 8 recetas a arreglar sobre 96. Eso es un radio acotado y pagable, no el salto al
100vacío que se temía. Lo que sí queda es hacer la lista COMPLETA antes de tocar `sandbox.rs`.
101
102## El experimento, CORRIDO (2026-09-18)
103
104**Diseño: no sella nada, a propósito.** Construir una receta modificada le cambia el `ArtifactHash`,
105así que medirlo a lo bruto habría dejado una docena de duplicados basura en un store que se respalda.
106En vez de eso, a cada receta de la muestra se le inyecta `set -e` al principio de cada fase **y un
107`exit 77` al final de la última**. El veredicto se lee en el código que reporta takana:
108
109- **falló con 77** ⇒ todas las fases corrieron enteras bajo `set -e` ⇒ **sobrevive** a `-ec`
110- **falló con ≠77** ⇒ una orden intermedia falla y hoy se está tragando ⇒ **se rompe** con `-ec`
111
112Nada llega al store. Las copias viven en `recipes/exp-ec-*.toml` y se barren al terminar.
113
114**Muestra:** 12 de las expuestas sin `set -e` propio, excluyendo los pesos pesados (`clang18`,
115kernels, `mesa`, navegadores) porque no caben en el presupuesto de tiempo. **Sesgo declarado:** la
116muestra es de recetas ligeras.
117
118| resultado | n |
119|---|---|
120| **sobrevive** sin tocar nada | **10** |
121| **se rompe** | **1** (`e2fsprogs`) |
122| inconcluso por tiempo (>7 min) | 1 (`cargo-edit`) |
123
124⚠ El recuento de expuestas sube de 89 a **96**: el 89 salía de restar 142−53 dando por hecho que las
12553 con `set -e` estaban todas dentro de las 142, y no lo estaban. Contadas una por una son 96.
126
127### El único que se rompe, mirado de cerca
128
129`e2fsprogs` muere en su `./configure`, que aborta con `configure: error: external uuid library not
130found` **pese a que la receta pasa `--disable-libuuid`**. Que hoy eso se traga está comprobado: la
131misma receta sin `set -e` y con `exit 77` **llega al 77**, o sea que la fase sigue entera después del
132configure fallido, y el `make` de después es el que decide el estado de salida.
133
134**Y el artefacto que sella está BIEN.** 149 ficheros, 21 M, con `e2fsck`, `mke2fs`, los cuatro
135`fsck.ext*`, los `mkfs.ext*` y `badblocks`. O sea que acá `-ec` **no atrapa un bug: rompe una receta
136que funciona.** Arreglarla es trabajo de receta —entender por qué configure se queja de algo que
137está desactivado— y no un fallo que estuviera escondido.
138
139Ése es justamente el coste que no se podía adivinar leyendo: **1 de 12, ~8%**. Sobre las 96 expuestas
140son **unas 8 recetas** que hay que tocar ANTES de cambiar el default. Es un número chico y acotado,
141que es lo que hacía falta para decidir.
142
143## Lo que queda por medir
144
145El experimento de arriba cubre la muestra ligera. Falta la cola pesada, que se excluyó por tiempo:
146`clang18`, los kernels, `mesa`, los navegadores. Son pocas recetas pero son las más largas, y no hay
147razón para esperar que se comporten distinto — sólo tardan más en decirlo. Se corren igual:
148
149 # reutilizar scripts/farm/exp-ec.sh (el mismo diseño: set -e + exit 77, no sella)
150 # con el lock por receta, NO por tanda: una tanda pesada retendría el lock horas y pararía la cosecha
151
152La validación completa es pasar las 96. Con ~8% de rotura esperada eso son unas 8 recetas a arreglar,
153y conviene tener la lista ENTERA antes de tocar `sandbox.rs`: cambiar el default y arreglar las
154recetas a medida que saltan deja el corpus a medio construir mientras tanto.
155
156## Consecuencias que se caen solas
157
158- Un `grep` de `Error [0-9]` sobre los logs de builds **sellados** es hoy una herramienta de auditoría
159 válida y barata. Cuatro de 88 no es ruido.
160- Cualquier receta nueva con fase multi-comando nace expuesta. Mientras (a) no se adopte, lo honesto
161 es que el último comando de una fase sea el que verifica — que es, sin haberlo dicho nunca, lo que
162 `zsh` ya hacía con su bucle de guardianes.
163- **Pedir algo que no puede funcionar y tolerar su fallo es peor que no pedirlo.** Es lo que se aplicó
164 en `3b36e9df`: `install.info` no se pide, en vez de pedirse y fallar en silencio.
0020-fases-de-build-sin-set-e.md
ADR 0020 — Las fases de build no abortan a la primera: sh -c sin -e
- Estado: PROPUESTO — hallazgo MEDIDO, un caso reparado y el experimento CORRIDO sobre muestra
ligera (§El experimento). Falta la cola pesada antes de adoptar (§Lo que queda por medir).
- 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 (un vacío llega hasta el final diciendo que
todo fue bien), ADR 0009 (direccionamiento por contenido).
Contexto
Las fases de una receta se ejecutan así, en los dos sitios donde se arma la orden del sandbox:
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.
Y el corpus YA resolvió esto a mano, en un tercio de los casos. 53 de esas 142 recetas abren
alguna fase con un set -e propio — wtype es una de ellas. Eso son dos datos, no uno:
- El default está mal y los autores ya lo sabían. Nadie escribe
set -e en 53 recetas por gusto;
se escribe después de que algo selle mal. Es la misma lección que este ADR, pagada de a una.
- Achica el radio de explosión. Esas 53 ya corren bajo
set -e y no romperían con -ec: la
migración no son 142 recetas, son 89 — las expuestas que todavía no se defienden solas.
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 — recomendada, pendiente de la cola pesada
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), y ya no a ciegas. El experimento de abajo pone el coste en ~8% de las
expuestas — unas 8 recetas a arreglar sobre 96. Eso es un radio acotado y pagable, no el salto al
vacío que se temía. Lo que sí queda es hacer la lista COMPLETA antes de tocar sandbox.rs.
El experimento, CORRIDO (2026-09-18)
Diseño: no sella nada, a propósito. Construir una receta modificada le cambia el ArtifactHash,
así que medirlo a lo bruto habría dejado una docena de duplicados basura en un store que se respalda.
En vez de eso, a cada receta de la muestra se le inyecta set -e al principio de cada fase y un
exit 77 al final de la última. El veredicto se lee en el código que reporta takana:
- falló con 77 ⇒ todas las fases corrieron enteras bajo
set -e ⇒ sobrevive a -ec
- falló con ≠77 ⇒ una orden intermedia falla y hoy se está tragando ⇒ se rompe con
-ec
Nada llega al store. Las copias viven en recipes/exp-ec-*.toml y se barren al terminar.
Muestra: 12 de las expuestas sin set -e propio, excluyendo los pesos pesados (clang18,
kernels, mesa, navegadores) porque no caben en el presupuesto de tiempo. Sesgo declarado: la
muestra es de recetas ligeras.
| resultado | n |
| sobrevive sin tocar nada | 10 |
| se rompe | 1 (e2fsprogs) |
| inconcluso por tiempo (>7 min) | 1 (cargo-edit) |
⚠ El recuento de expuestas sube de 89 a 96: el 89 salía de restar 142−53 dando por hecho que las
53 con set -e estaban todas dentro de las 142, y no lo estaban. Contadas una por una son 96.
El único que se rompe, mirado de cerca
e2fsprogs muere en su ./configure, que aborta con configure: error: external uuid library not found pese a que la receta pasa --disable-libuuid. Que hoy eso se traga está comprobado: la
misma receta sin set -e y con exit 77 llega al 77, o sea que la fase sigue entera después del
configure fallido, y el make de después es el que decide el estado de salida.
Y el artefacto que sella está BIEN. 149 ficheros, 21 M, con e2fsck, mke2fs, los cuatro
fsck.ext*, los mkfs.ext* y badblocks. O sea que acá -ec no atrapa un bug: rompe una receta
que funciona. Arreglarla es trabajo de receta —entender por qué configure se queja de algo que
está desactivado— y no un fallo que estuviera escondido.
Ése es justamente el coste que no se podía adivinar leyendo: 1 de 12, ~8%. Sobre las 96 expuestas
son unas 8 recetas que hay que tocar ANTES de cambiar el default. Es un número chico y acotado,
que es lo que hacía falta para decidir.
Lo que queda por medir
El experimento de arriba cubre la muestra ligera. Falta la cola pesada, que se excluyó por tiempo:
clang18, los kernels, mesa, los navegadores. Son pocas recetas pero son las más largas, y no hay
razón para esperar que se comporten distinto — sólo tardan más en decirlo. Se corren igual:
# reutilizar scripts/farm/exp-ec.sh (el mismo diseño: set -e + exit 77, no sella)
# con el lock por receta, NO por tanda: una tanda pesada retendría el lock horas y pararía la cosecha
La validación completa es pasar las 96. Con ~8% de rotura esperada eso son unas 8 recetas a arreglar,
y conviene tener la lista ENTERA antes de tocar sandbox.rs: cambiar el default y arreglar las
recetas a medida que saltan deja el corpus a medio construir mientras tanto.
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.