deb: wipe the staged tree when overlay staging falls back to a copy

On a resumed session whose overlay mount fails (no overlayfs, mount
error, non-overlay driver), the fallback used to copy the host tree
into the staged tree of the previous attempt: a merge that keeps the
old build stamps and resurrects files deleted on the host — a
silently half-stale build, exactly what resume must never produce.
The driver now reports the failure instead of copying, and the
caller wipes the staged build root and re-stages a fresh copy
(environment resumable, build artifacts discarded).
This commit is contained in:
2026-09-27 23:32:16 +02:00
parent 318bcea341
commit 961f6bc234
3 changed files with 89 additions and 37 deletions
+23 -29
View File
@@ -234,39 +234,33 @@ impl ContextDriver for UnshareDriver {
// The upper/work dirs are the persistent ones of a build session: // The upper/work dirs are the persistent ones of a build session:
// mounting them again exposes the previous attempt's build // mounting them again exposes the previous attempt's build
// artifacts over the (live) host tree. // artifacts over the (live) host tree. Mount failure is an ERROR,
if src.is_dir() && is_overlayfs_available() { // never a silent copy fallback: a copy would merge the host tree
match self.mount_overlay(src, &dest_path, Some(upper), Some(work)) { // into the stale staged tree of the previous attempt (old build
Ok(()) => { // stamps, dropped host deletions) — the caller decides to wipe and
debug!( // re-stage instead.
"Mounted overlay for {} at {} (reused upper {})", if !src.is_dir() {
src.display(), return Err(io::Error::new(
dest_path.display(), io::ErrorKind::Unsupported,
upper.display() "overlay staging only applies to directories",
); ));
return Ok(super::api::OverlayStaging {
path: Path::new(dest_root).join(filename),
overlay: true,
});
}
Err(e) => {
debug!(
"Overlay remount failed for '{}', falling back to copy: {}",
src.display(),
e
);
}
}
} }
if !is_overlayfs_available() {
if src.is_dir() { return Err(io::Error::new(
copy_dir_recursive(src, &dest_path)?; io::ErrorKind::Unsupported,
} else { "overlayfs is not available on this system",
copy_file_with_times(src, &dest_path)?; ));
} }
self.mount_overlay(src, &dest_path, Some(upper), Some(work))?;
debug!(
"Mounted overlay for {} at {} (reused upper {})",
src.display(),
dest_path.display(),
upper.display()
);
Ok(super::api::OverlayStaging { Ok(super::api::OverlayStaging {
path: Path::new(dest_root).join(filename), path: Path::new(dest_root).join(filename),
overlay: false, overlay: true,
}) })
} }
+14 -5
View File
@@ -361,9 +361,12 @@ async fn build_binary_package_impl(
} }
let staged_path = if let Some(s) = &session { let staged_path = if let Some(s) = &session {
// Re-mount the session's persistent upperdir over the host // Re-mount the session's persistent upperdir over the host
// tree; a driver without overlay support (or a failed mount) // tree. If overlay staging is unavailable (no overlayfs, mount
// falls back to a fresh copy — still environment-resumable, // failure, non-overlay driver), wipe the staged tree and
// just not artifact-resumable. // re-stage a fresh copy: copying over the previous attempt's
// tree would merge stale build artifacts and resurrect
// host-deleted files into the build. Still environment-
// resumable, just not artifact-resumable.
let staging = match build_ctx.ensure_available_with_overlay( let staging = match build_ctx.ensure_available_with_overlay(
&parent_dir, &parent_dir,
&build_root, &build_root,
@@ -371,14 +374,20 @@ async fn build_binary_package_impl(
&s.work_dir(), &s.work_dir(),
) { ) {
Ok(staging) => staging, Ok(staging) => staging,
Err(e) if e.kind() == std::io::ErrorKind::Unsupported => { Err(e) => {
log::info!(
"Overlay staging unavailable ({}): the staged tree is \
re-staged from scratch, build artifacts are discarded",
e
);
s.wipe_staged_tree()?;
let path = build_ctx.ensure_available(&parent_dir, &build_root)?; let path = build_ctx.ensure_available(&parent_dir, &build_root)?;
build_resume = false;
crate::context::OverlayStaging { crate::context::OverlayStaging {
path, path,
overlay: false, overlay: false,
} }
} }
Err(e) => return Err(e.into()),
}; };
if !staging.overlay { if !staging.overlay {
// Copy fallback: the staged tree is fresh, there is nothing // Copy fallback: the staged tree is fresh, there is nothing
+52 -3
View File
@@ -312,7 +312,7 @@ impl Session {
/// The advisory lock file path: `<sessions root>/<slug>.lock`, outside /// The advisory lock file path: `<sessions root>/<slug>.lock`, outside
/// the session root it guards (see [`Session::acquire`]). /// the session root it guards (see [`Session::acquire`]).
fn lock_path(&self) -> PathBuf { pub(crate) fn lock_path(&self) -> PathBuf {
let name = self let name = self
.root .root
.file_name() .file_name()
@@ -343,9 +343,33 @@ impl Session {
} }
} }
/// Wipe the staged tree inside the chroot (the stable build root).
///
/// Used when overlay staging is unavailable on a resumed session: the
/// fallback re-stages a fresh copy, and copying over the previous
/// attempt's tree would merge stale build artifacts and resurrect
/// host-deleted files into the build.
pub fn wipe_staged_tree(&self) -> io::Result<()> {
let staged = self.chroot_path().join("tmp/pkh-build");
fs::create_dir_all(&staged)?;
for entry in fs::read_dir(&staged)?.flatten() {
let path = entry.path();
let result = if path.is_dir() {
fs::remove_dir_all(&path)
} else {
fs::remove_file(&path)
}
.or_else(|_| crate::deb::ephemeral::privileged_remove(&path));
result
.map_err(|e| io::Error::other(format!("cannot clear {}: {e}", staged.display())))?;
}
Ok(())
}
/// Remove the whole session tree (unmounting anything mounted under /// Remove the whole session tree (unmounting anything mounted under
/// it first). The lock file is deleted along with the root; the flock /// it first), then unlink the lock file. The caller holds the flock;
/// stays held by this handle until it drops. /// an opener that raced ahead of the unlink failed its lock attempt
/// while we held it, and the next one creates a fresh session.
pub fn remove(&self) { pub fn remove(&self) {
if let Err(e) = remove_session_dir(&self.root) { if let Err(e) = remove_session_dir(&self.root) {
log::warn!( log::warn!(
@@ -354,6 +378,7 @@ impl Session {
e e
); );
} }
let _ = fs::remove_file(self.lock_path());
} }
} }
@@ -649,6 +674,30 @@ impl Session {
} }
} }
/// Whether the lock file at `path` is currently held by a live build
/// (flock taken). `Ok(false)` also covers a missing file.
pub(crate) fn lock_is_held(path: &Path) -> io::Result<bool> {
use std::os::unix::io::AsRawFd;
let file = match fs::OpenOptions::new().read(true).open(path) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e),
};
let taken = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if taken == 0 {
// Nobody holds it: release the probe's own lock and report free.
unsafe {
libc::flock(file.as_raw_fd(), libc::LOCK_UN);
}
return Ok(false);
}
let err = io::Error::last_os_error();
if err.kind() == io::ErrorKind::WouldBlock {
return Ok(true);
}
Err(err)
}
/// Whether the cached chroot tarball the session was bootstrapped from is /// Whether the cached chroot tarball the session was bootstrapped from is
/// still the one the current bootstrap would use (name, then size/mtime, /// still the one the current bootstrap would use (name, then size/mtime,
/// falling back to the recorded SHA-256 when the cheap check drifts). /// falling back to the recorded SHA-256 when the cheap check drifts).