hydrate: instalar a otro filesystem ya no muere — EXDEV se copia, que es lo único posible
Lo encontró la instalación del modelo opcional del §6.3: `takana install --prefix` murió con
`Invalid cross-device link (os error 18)` justo en el caso que su propia ayuda promete por escrito
(«útil para tests offline o staging en otro filesystem»).
⚠ Y no hace falta tener dos discos para pegarse con esto: `linkat` da EXDEV **entre dos MOUNTS del
mismo dispositivo**, y el store de esta máquina es un bind-mount del volumen. O sea que el caso es
normal, no exótico.
Ahora, si el hardlink da EXDEV —y sólo si da EXDEV; cualquier otro error sigue siendo un error— se
copia. El precio es el espacio: el fichero deja de compartir inode con el store. Está aceptado,
porque la alternativa es no poder instalar. El encabezado del módulo, que prometía «cero copia», dice
ahora la excepción.
Test con el caso real (tmpfs de /dev/shm contra el temporal, que son mounts distintos) y comprobado
en los dos sentidos: quitando la excepción de EXDEV, el test cae con el mismo error que reportaba el
instalador. Si en alguna máquina esos dos caminos fueran el mismo mount, el test lo DICE en vez de
saltarse en silencio.
De paso: `swm_bridge.rs` tenía un constructor de test sin el campo `strip_debug` que agregó 9bc51889,
y eso dejaba al crate ENTERO sin compilar sus tests. Completado.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
//! Hidratación: del store al FHS. Ver `docs/03-hydration.md`.
|
||||
//!
|
||||
//! Estrategia primaria (`LinkMode::Static`): hardlink directo. Cero copia, cero RPATH que
|
||||
//! Estrategia primaria (`LinkMode::Static`): hardlink directo. Cero copia —salvo que el destino
|
||||
//! esté en otro filesystem, donde `linkat` da EXDEV y no queda más que copiar—, cero RPATH que
|
||||
//! reescribir. El FHS queda con archivos reales (no symlinks) compartiendo inode con el store.
|
||||
//! Pisar un archivo del FHS rompe el hardlink (CoW al kernel) sin tocar el artefacto del store
|
||||
//! — base de rollback (`takana hydrate <hash>` lo restaura).
|
||||
@@ -202,13 +203,31 @@ fn atomic_hardlink(src: &Path, dst: &Path) -> takana_core::Result<()> {
|
||||
let tmp = with_suffix(dst, ".hammer-tmp");
|
||||
// Limpia un tmp olvidado de una corrida anterior.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
std::fs::hard_link(src, &tmp).map_err(|e| {
|
||||
takana_core::Error::Store(format!(
|
||||
"hardlink {} → {}: {e}",
|
||||
src.display(),
|
||||
tmp.display()
|
||||
))
|
||||
})?;
|
||||
if let Err(e) = std::fs::hard_link(src, &tmp) {
|
||||
// ⚠ EXDEV NO ES UN FALLO: es «el destino está en otro sistema de ficheros». Y `linkat` lo
|
||||
// devuelve incluso entre dos MOUNTS del mismo dispositivo —un bind-mount cuenta como otro—,
|
||||
// así que pasa en máquinas perfectamente normales: el store bindeado a un volumen y un
|
||||
// `--prefix` fuera de él. Sin este camino, `takana install --prefix` moría con
|
||||
// `Invalid cross-device link` justo en el caso que su propia ayuda promete («staging en otro
|
||||
// filesystem»). Medido el 2026-09-12 instalando el modelo opcional del §6.3.
|
||||
//
|
||||
// Se COPIA, que es lo único posible: el precio es el espacio (deja de compartir inode con el
|
||||
// store) y está aceptado, porque la alternativa es no poder instalar.
|
||||
if e.raw_os_error() != Some(libc::EXDEV) {
|
||||
return Err(takana_core::Error::Store(format!(
|
||||
"hardlink {} → {}: {e}",
|
||||
src.display(),
|
||||
tmp.display()
|
||||
)));
|
||||
}
|
||||
std::fs::copy(src, &tmp).map_err(|e2| {
|
||||
takana_core::Error::Store(format!(
|
||||
"el destino está en otro filesystem y la copia {} → {} también falló: {e2}",
|
||||
src.display(),
|
||||
tmp.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
// rename sobreescribe atómicamente si dst es un archivo (no si es directorio).
|
||||
if let Err(e) = std::fs::rename(&tmp, dst) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
@@ -476,4 +495,48 @@ mod tests {
|
||||
let dst_ino = std::fs::metadata(target.path().join("usr/bin/sh")).unwrap().ino();
|
||||
assert_ne!(src_ino, dst_ino, "ELF parcheado ⇒ copia ⇒ inode distinto");
|
||||
}
|
||||
|
||||
/// ⚠ El destino en OTRO filesystem no es un error: es un `--prefix` de staging, que la ayuda de
|
||||
/// `install` promete por su nombre. `linkat` devuelve EXDEV y hay que copiar.
|
||||
///
|
||||
/// La prueba usa `/dev/shm` (tmpfs) contra el temporal del test, que están en mounts distintos.
|
||||
/// Si en esta máquina no lo estuvieran, el test se calla — pero NO en silencio: imprime por qué,
|
||||
/// porque un test que se salta sin decirlo es un test que no existe.
|
||||
#[test]
|
||||
fn hidratar_a_otro_filesystem_copia_en_vez_de_morir() {
|
||||
let origen = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(origen.path().join("usr/share")).unwrap();
|
||||
let fichero = origen.path().join("usr/share/dato.bin");
|
||||
std::fs::write(&fichero, b"contenido").unwrap();
|
||||
|
||||
let destino = std::path::Path::new("/dev/shm").join(format!("takana-exdev-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&destino);
|
||||
std::fs::create_dir_all(&destino).unwrap();
|
||||
|
||||
// ¿Están de verdad en mounts distintos? Si no, no hay nada que medir acá.
|
||||
let sonda = destino.join("sonda");
|
||||
let cruza = std::fs::hard_link(&fichero, &sonda)
|
||||
.err()
|
||||
.map(|e| e.raw_os_error() == Some(libc::EXDEV))
|
||||
.unwrap_or(false);
|
||||
let _ = std::fs::remove_file(&sonda);
|
||||
if !cruza {
|
||||
eprintln!("saltado: /dev/shm y el tmp de este test están en el MISMO mount");
|
||||
let _ = std::fs::remove_dir_all(&destino);
|
||||
return;
|
||||
}
|
||||
|
||||
let informe = hydrate(origen.path(), &destino, LinkMode::Static, None).unwrap();
|
||||
assert_eq!(informe.files.len(), 1, "{informe:?}");
|
||||
let copiado = destino.join("usr/share/dato.bin");
|
||||
assert_eq!(std::fs::read(&copiado).unwrap(), b"contenido");
|
||||
// Copia, no hardlink: inodes distintos. Es el precio aceptado — el espacio a cambio de poder
|
||||
// instalar fuera del filesystem del store.
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
assert_ne!(
|
||||
std::fs::metadata(&fichero).unwrap().ino(),
|
||||
std::fs::metadata(&copiado).unwrap().ino()
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&destino);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +316,9 @@ logo = "b3:azul"
|
||||
flags: vec!["--enable-foo".into()],
|
||||
phases: Default::default(),
|
||||
zig_version: None,
|
||||
// Agregado en 9bc51889 («no viajaba en el paquete, y es ENTRADA DE HASH»): este
|
||||
// constructor de test se quedó sin él y dejó al crate ENTERO sin compilar sus tests.
|
||||
strip_debug: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user