gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ main docs/adr/0020-fases-de-build-sin-set-e.md Markdown · 164 líneas · 9.4 KB Historia crudo

ADR 0020: el experimento CORRIDO — 10 de 12 sobreviven a `-ec`, y el que cae tiene el artefacto BIEN 27ec9caea · Sergio ·

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

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:

casoveredicto
zsh → install.infoHUECO REAL, CONFIRMADO. /usr/share/info vacío en el artefacto. Reparado en 3b36e9df
python3 → libpython3.12.aNO 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

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:

  1. 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.
  2. 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:

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.

resultadon
sobrevive sin tocar nada10
se rompe1 (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