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 1576Shell 205Rust 111Markdown 993774 Definitions

⎇ 48a9aff129b9edc4aaaf9c68a3fdb0d9b39a720d docs/adr/0020-fases-de-build-sin-set-e.md Markdown · 113 lines · 6.2 KB History raw

ADR 0020: las fases corren con `sh -c` sin `-e`, y un fallo no final sella igual 48a9aff12 · Sergio ·

1# ADR 0020 — Las fases de build no abortan a la primera: `sh -c` sin `-e`
2
3- **Estado:** PROPUESTO — el hallazgo está MEDIDO y un caso está reparado; la decisión (`-ec` sí o
4  no) queda abierta y depende de un experimento que todavía no se corrió (§Precondiciones).
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## Lo que hoy tapa el agujero, y por qué no alcanza
64
65En `zsh` el artefacto se salvó de verdad — pero no por el estado de salida, sino por **dos guardianes
66que el autor de la receta escribió a mano**: uno mira el ELF por `INTERP`, otro pregunta al binario
67si tiene sus módulos. Funcionaron. El problema es que son **específicos de zsh**: comprueban las dos
68cosas que su autor supo prever. Una receta sin guardianes propios no tiene ninguna red, y hoy son la
69minoría.
70
71Y hay una asimetría que conviene nombrar: los guardianes corren **después** del `make` precisamente
72*porque* el shell no aborta. Si mañana se pone `-e`, esas recetas dejan de sellar — no porque el
73artefacto sea peor, sino porque el fallo que hoy se tolera pasa a ser mortal.
74
75## Decisión — ABIERTA
76
77Las tres salidas, con lo que cada una cuesta:
78
79**a) `sh -ec` (abortar a la primera).** Correcto por defecto y alineado con la regla 3. Radio de
80explosión: hasta 142 recetas, **muchas de ellas ya selladas y en uso**. Un build que hoy pasa
81tolerando un fallo benigno (un `rm` de algo que no existe, un `install` opcional) pasaría a fallar.
82No re-hashea nada —el shell no entra en `ArtifactHash`— pero puede dejar el corpus sin reconstruir.
83
84**b) `-e` por receta** (`strict_phases = true`), migrando de a poco. Sin big bang, pero deja el
85default inseguro, que es lo que causó esto.
86
87**c) Dejarlo y exigir guardianes.** Es el statu quo, y el statu quo produjo un artefacto con un
88directorio vacío que nadie vio durante un día.
89
90**Recomendación: (a), pero no antes de la medición de abajo.** Cambiar el default a ciegas sobre un
91corpus de 1115 sellados es exactamente el reflejo que este repo evita.
92
93## Precondiciones — el experimento que falta
94
95Lo que hay hoy es un **cribado sobre logs**, no una medición del radio de explosión. El número que
96falta es: *¿cuántas de las 142 recetas expuestas dejan de sellar bajo `-ec`?* Y no se puede sacar
97leyendo: hay que reconstruirlas.
98
99    # sobre una muestra de las 142, con el lock tomado una sola vez (CLAUDE.md regla 1)
100    flock -o work/.farm-build.lock bash -c 'for r in <muestra>; do takana --store ./store build "$r"; done'
101
102Con `-ec` puesto y contando los que caen. Una muestra de 20 alcanza para decidir; el corpus entero es
103la validación. **Hasta que ese número exista, este ADR no se adopta.**
104
105## Consecuencias que se caen solas
106
107- Un `grep` de `Error [0-9]` sobre los logs de builds **sellados** es hoy una herramienta de auditoría
108  válida y barata. Cuatro de 88 no es ruido.
109- Cualquier receta nueva con fase multi-comando nace expuesta. Mientras (a) no se adopte, lo honesto
110  es que el último comando de una fase sea el que verifica — que es, sin haberlo dicho nunca, lo que
111  `zsh` ya hacía con su bucle de guardianes.
112- **Pedir algo que no puede funcionar y tolerar su fallo es peor que no pedirlo.** Es lo que se aplicó
113  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

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