Files
takana/scripts/farm/campana-deuda.sh
T
SergioandClaude Opus 5 5247821455 granja: el lock ya no se lo puede quedar un proceso fugado
El lock lo sostiene la descripción de fichero abierta y los hijos la heredan: con 'exec 9>' +
'flock 9' cualquier descendiente que sobreviva al script retiene el lock para siempre, y el
siguiente que lo pida espera sin que nada falle. Medido hoy: dos firefox colgados de una caza
sobrevivieron al kill de su bwrap y dejaron a la granja hora y media sin poder compilar.

Dos formas de defecto ⇒ dos arreglos, y no son intercambiables:

  · cosecha-cron, campana-deuda, harvest-harkaq — el lock cubre el script ENTERO, así que se
    re-ejecutan bajo 'flock -o' (cierra el fd antes de ejecutar). Ir poniendo '9>&-' comando a
    comando ahí es jugar a los topos. '-E 77' separa 'estaba ocupado' de 'el trabajo falló',
    que con el 9> no se distinguían.

  · farm-worker-loop, latido — el lock vive en un subshell / es a propósito el mecanismo de
    vida, así que basta '9>&-' en los hijos. En latido NO se puede usar -o: ahí el fd retenido
    ES como --status sabe que el latido vive sin pidfile. Su modo de fallo era el peor de
    todos: un nieto fugado dejaba el latido MUERTO PERO APARENTANDO ESTAR VIVO.

Medido antes de elegir, no deducido del manual:
  exec 9> + flock 9      → el nieto RETIENE  (control negativo: reproduce el fallo)
  exec {L}> (fd auto)    → el nieto RETIENE  (bash NO lo marca close-on-exec)
  flock -o / 9>&- hijo   → lock LIBRE

Y probados los cinco sobre el fichero real, no sobre una maqueta: copias truncadas justo tras
el bloque del lock, invocadas con RUTA RELATIVA DESDE OTRO DIRECTORIO (que es lo que rompe un
$0 sin resolver — de hecho la primera versión de harvest-harkaq calculaba YO después del cd y
habría fallado ahí). Los tres entran, ninguno deja el lock tomado por el nieto, y la exclusión
mutua sigue funcionando con su mensaje y su exit 0 — que es lo que un arreglo de locks puede
romper en silencio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QPJteswQvP1L7zSBrQQe2
2026-09-08 16:34:55 +00:00

169 lines
11 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/sh
# campana-deuda.sh — muele en el WORKER una lista EXPLÍCITA de recetas en deuda, medida en el HUB.
#
# POR QUÉ NO `saldar-deuda-static.sh` DIRECTO EN EL WORKER (2026-07-21): ese script calcula la deuda
# en vivo con `hammer hash --check` contra el store LOCAL. En el worker el store es PARCIAL (el del
# volumen: ~290 artefactos vs 720 del hub) ⇒ mide 716 recetas de deuda en vez de 37 y se pone a
# reconstruir media distro. Es la regla de [[frente-harkaq-jaula]] con otra cara: **el worker MIDE,
# el hub CLASIFICA**. Acá el hub decide la lista (`DRY=1 saldar-deuda-static.sh`) y el worker sólo
# ejecuta. La lista se pasa por argumentos o por DEUDA=, no se recalcula.
#
# ORDEN: las RAÍCES del stack GUI primero (glib unblocks=14, luego harfbuzz/cairo/pango → gtk4 →
# libadwaita). `hammer build` arrastra deps recursivamente, así que el orden no es obligatorio —
# pero empezar por la raíz hace que la cascada caiga cuanto antes y que un corte temprano deje el
# máximo destrabado. El stack GUI es lo que el laptop NO puede construir (zig-skew, ver
# [[etapa-g-gui-chain-boundary]]) ⇒ es exactamente lo que justifica la granja.
#
# PATH: `go mod vendor` y demás corren HOST-SIDE (fuera del sandbox) ⇒ el `go` del store tiene que
# estar en el PATH. Una sesión SSH no interactiva no carga /etc/profile ni cargo/env: sin esto toda
# receta Go muere con "spawn go mod vendor: No such file or directory".
#
# Uso (en el worker): scripts/farm/campana-deuda.sh <receta> [receta…]
# DEUDA="glib cairo" scripts/farm/campana-deuda.sh
# Lanzarlo desde el hub para que sobreviva al cierre del SSH:
# ssh root@<ip> 'systemd-run --unit=campana-deuda --working-directory=/opt/hammer \
# /opt/hammer/scripts/farm/campana-deuda.sh <recetas…>'
# ssh root@<ip> 'journalctl -u campana-deuda -f'
set -u
HAMMER_DIR="${HAMMER_DIR:-/opt/hammer}"
# Ruta absoluta a MÍ MISMO antes del `cd`: acá el `cd` va a HAMMER_DIR, que ni siquiera tiene por
# qué contener a este script, así que un `$0` relativo se pierde seguro.
YO="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
cd "$HAMMER_DIR"
HAMMER="${HAMMER:-$HAMMER_DIR/target/release/hammer}"
STORE="${STORE:-$HAMMER_DIR/store}"
LOG="${LOG:-/var/log/campana-deuda.log}"
# go del store (host-side) + cargo, para que las recetas Go/Rust no mueran por PATH
for g in "$STORE"/*-go/usr/bin; do [ -d "$g" ] && PATH="$g:$PATH"; done
HOME="${HOME:-/root}"; export HOME # systemd-run no hereda HOME y `set -u` lo convierte en fatal
[ -r "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
export PATH
DEUDA="${DEUDA:-$*}"
[ -n "$DEUDA" ] || { echo "uso: campana-deuda.sh <receta> [receta…] (o DEUDA=…)" >&2; exit 2; }
ts() { date -u +%FT%TZ; }
# LOCK COMPARTIDO CON EL WORKER-LOOP (2026-07-22). Esta campaña y `farm-worker-loop.sh` construyen
# sobre el MISMO work/, y `fetch` nombra el árbol de fuentes de forma determinista
# (`work/sources/<receta>-<sha16>`) ⇒ dos procesos que construyan la misma DEP apuntan al mismo
# directorio, y uno hace `remove_dir_all` mientras el otro corre `tar -x`. Eso deja el árbol a medias
# y devuelve "Directory not empty" (os error 39) — y el árbol roto se queda así hasta que alguien lo
# borra a mano. Pasó de verdad: la campaña y el loop pidieron `mesa` a la vez (el loop lo arrastraba
# desde la cola KDE) y se llevó puesta media cascada GUI.
#
# El lock es EXCLUSIVO y de grano grueso: la campaña lo toma para TODA su corrida y el loop lo toma
# por cada ciclo de cola. No se puede afinar más sin romper el `xargs -P2` del loop.
# `flock` NO es FIFO: como el loop vuelve a pedirlo enseguida, en la práctica la campaña entra en la
# ventana del IDLE_SLEEP, no entre dos colas (medido, no supuesto). De ahí el techo de espera.
#
# OJO, lo que este lock NO cubre: el propio `-P2` del loop puede correr dos recetas que compartan
# dep, y ésas se pisan igual. El arreglo de fondo es un lock POR ÁRBOL dentro de `fetch`.
#
# Espera bloqueante con techo: un ciclo de cola puede ser largo, pero colgarse para siempre sería
# peor que no correr. Al vencer, sale limpio y lo dice; el próximo lanzamiento reintenta.
LOCK_FARM="${LOCK_FARM:-$HAMMER_DIR/work/.farm-build.lock}"
LOCK_WAIT="${LOCK_WAIT:-7200}"
mkdir -p "$(dirname "$LOCK_FARM")"
# ⚠ EL LOCK LO SOSTIENE EL fd, Y LOS HIJOS LO HEREDAN (medido 2026-09-08). Con `exec 9>` + `flock 9`
# cualquier descendiente que se fugue retiene el lock DESPUÉS de que el script termine, y el próximo
# que lo pida espera para siempre sin que nada falle. Pasó: dos `firefox` colgados de una caza de
# bugs sobrevivieron al `kill` de su `bwrap` y dejaron a la granja hora y media sin poder compilar,
# en silencio. `fuser -v <lock>` es lo que lo delata.
# El arreglo es `flock -o`, que cierra el fd antes de ejecutar ⇒ el lock lo sostiene SÓLO el
# proceso `flock`, y ningún nieto puede prolongarlo. Como acá la sección crítica es el script
# ENTERO, se re-ejecuta bajo `flock -o` en vez de ir poniendo `9>&-` comando por comando, que es
# jugar a los topos y se olvida uno. `-E 77` distingue «el lock estaba ocupado» de «el trabajo
# falló», que con el `9>` de antes no se podían separar.
# Comprobado con control positivo y negativo: sin `-o` el nieto retiene; con `-o` no. Y que la
# exclusión mutua SIGUE funcionando, que es lo que un arreglo de locks puede romper sin avisar.
# ⚠ Ojo: `exec {L}>` (fd automático de bash) NO sirve — bash no lo marca close-on-exec. Medido.
if [ -z "${CAMPANA_DEUDA_CON_LOCK:-}" ]; then
export CAMPANA_DEUDA_CON_LOCK=1
rc=0; flock -w "$LOCK_WAIT" -E 77 -o "$LOCK_FARM" "$YO" "$@" || rc=$?
if [ "$rc" = 77 ]; then
echo "$(ts) campaña-deuda: el worker-loop no soltó el lock en ${LOCK_WAIT}s ⇒ salgo sin construir" | tee -a "$LOG"
exit 0
fi
exit "$rc"
fi
echo "$(ts) campaña-deuda: lock de build tomado (el worker-loop espera su turno)" | tee -a "$LOG"
total=$(echo "$DEUDA" | wc -w); i=0; ok=0; fail=0; falladas=""; ultimo_rc=0
echo "$(ts) campaña-deuda arranca: $total recetas | go=$(command -v go || echo AUSENTE)" | tee -a "$LOG"
# ── GUARDIÁN DE DISCO (2026-09-04) ──────────────────────────────────────────────────────────────
# POR QUÉ. La cascada KDE del 2026-09-03 llenó el disco en la receta 52 de 71 y siguió moliendo: las
# 8 restantes murieron cada una en 1-20 s con `error in backend: IO failure on output stream: No
# space left on device`, y el bucle las anotó `✗` — el MISMO símbolo que una receta que de verdad no
# compila. Al día siguiente el grafo decía «8 en deuda, clase c» y eso mandó a leer recetas durante
# un rato buscando un bug que no existía. Un disco lleno no es un fallo de receta: es un fallo de la
# MÁQUINA, y confundirlos cuesta el triaje entero.
#
# Además el trabajo posterior al corte es puro desperdicio: 4 minutos quemados produciendo ✗.
#
# DÓNDE SE MIDE: en DOS filesystems, no en uno. En gioser `store/` y `work/` son discos DISTINTOS
# (`/dev/sdb` el store, `/dev/sdc` el resto del repo) y el que se llenó esa noche fue el de `work/`:
# ahí viven `work/sources/<receta>-<sha>` y el árbol de build, que es lo que crece ~300 MB por
# receta. Un guardián que mirara sólo el store habría leído 109 G libres y dejado moler igual —
# el fallo clásico de calibrar el vigía en una escala que el dato nunca alcanza. Se toma el MÍNIMO
# de los dos: cualquiera de los dos lleno mata el build igual.
#
# Suelo en GiB. 8 G es ~1,5 veces el árbol+build de la receta más gorda que vimos (kwin, qt6); por
# debajo de eso el siguiente build es una apuesta, no una tarea.
SUELO_GIB="${SUELO_GIB:-8}"
avail_gib() { df -B1G --output=avail "$1" 2>/dev/null | tail -1 | tr -d ' '; }
libres_gib() {
a="$(avail_gib "$STORE")"; b="$(avail_gib "$HAMMER_DIR/work")"
[ -n "$a" ] || { echo "$b"; return; }
[ -n "$b" ] || { echo "$a"; return; }
[ "$a" -le "$b" ] && echo "$a" || echo "$b"
}
for r in $DEUDA; do
i=$((i + 1))
f="recipes/$r.toml"
[ -r "$f" ] || f="$(ls recipes/incoming*/"$r".toml 2>/dev/null | head -1)"
[ -n "$f" ] && [ -r "$f" ] || { echo "$(ts) [$i/$total] $r ⚠ sin receta" | tee -a "$LOG"; continue; }
echo "$(ts) [$i/$total] → $r" | tee -a "$LOG"
libres="$(libres_gib)"
if [ -n "$libres" ] && [ "$libres" -lt "$SUELO_GIB" ]; then
echo "$(ts) [$i/$total] ⛔ DISCO: ${libres} G libres (min de store=$(avail_gib "$STORE") y work=$(avail_gib "$HAMMER_DIR/work")) < suelo ${SUELO_GIB} G ⇒ corto la campaña" | tee -a "$LOG"
echo "$(ts) campaña-deuda ABORTADA por disco: $ok selladas, $fail fallidas, $((total - i + 1)) SIN INTENTAR" | tee -a "$LOG"
echo "$(ts) sin intentar: $(echo "$DEUDA" | tr ' ' '\n' | tail -n +"$i" | tr '\n' ' ')" | tee -a "$LOG"
exit 3
fi
# heartbeat: el dead-man ya cuenta `hammer build` como trabajo, pero entre receta y receta hay
# huecos (fetch/vendor) donde no hay proceso con ese nombre. Tocarlo evita una muerte espuria.
touch /run/hammer-heartbeat 2>/dev/null || true
# build-timed.sh mide la pared y registra la duración en $STORE/.times (sólo builds reales), para
# el camino crítico pesado de yupana. Transparente: sale con el mismo código que `hammer build`.
if HAMMER="$HAMMER" STORE="$STORE" "$HAMMER_DIR/scripts/farm/build-timed.sh" "$f" >>"$LOG" 2>&1; then
ultimo_rc=0; ok=$((ok + 1)); echo "$(ts) [$i/$total] ✓ $r" | tee -a "$LOG"
else
ultimo_rc=1; fail=$((fail + 1)); falladas="$falladas $r"; echo "$(ts) [$i/$total] ✗ $r" | tee -a "$LOG"
fi
# PODA_FUENTES=1 — borra el árbol de fuentes DENTRO del bucle, que es el único sitio donde es
# seguro: acá tenemos el lock Y acabamos de terminar un build, así que no hay ningún `bwrap`
# usando un árbol. Es lo contrario de correr `poda-fuentes.sh` en paralelo, que sería el ADR 0012
# en su forma más directa (su propio encabezado avisa que el mtime NO distingue «viejo» de «lento»).
# No cuesta nada: `fetch.rs` borra y re-extrae el árbol en CADA build, no lo reutiliza jamás.
# Nace en la cascada de kcoreaddons (2026-09-03), 71 recetas KDE en una máquina con 16 G libres:
# a ~300 MB de árbol por receta, sin esto la campaña se come el disco antes de la onda 4.
#
# POR DEFECTO 1 (2026-09-04). Nació apagado y la cascada KDE del 2026-09-03 corrió sin ponerlo:
# 71 recetas × ~300 MB de árbol llenaron /dev/sdc en la 52 y se llevaron puestas las 8 últimas.
# Un default que hay que acordarse de encender no es una defensa. Apagar con PODA_FUENTES=0.
#
# NO se poda tras un ✗: el árbol de una receta que falló es el post-mortem — sin él no se puede
# leer qué generó cmake ni con qué flags murió, y es justo el caso en que alguien va a mirar.
# Es el mismo criterio del suelo de 24 h de poda-fuentes.sh, aplicado por resultado y no por edad.
if [ "${PODA_FUENTES:-1}" = 1 ] && [ "$ultimo_rc" = 0 ]; then
rm -rf "$HAMMER_DIR"/work/sources/* 2>/dev/null || true
fi
done
echo "$(ts) campaña-deuda fin: $ok selladas, $fail fallidas de $total" | tee -a "$LOG"
[ -n "$falladas" ] && echo "$(ts) fallidas:$falladas" | tee -a "$LOG"
exit 0