deb: add resumable build sessions

Every local build records a session under /var/tmp/pkh/sessions:
the bootstrapped chroot, the installed build dependencies, the
phase journal and the staged tree with a persistent overlay
upperdir. Rebuilding after a failure currently redoes the tarball
extraction, apt update, build-dep resolution and the whole compile;
a kernel-sized package loses half an hour per iteration.

Recording is always on, reuse is opt-in (--resume [<id>]): the
newest session of the tree, or the one matching an id as shown by
'pkh deb list' (one session per series/arch/cross identity, ids
are build-start timestamps). Resume skips the chroot bootstrap
(integrity marker + tarball check), re-mounts the previous
upperdir so make recompiles only what changed, pops the quilt
series first when debian/patches changed, and clears debian/files
before re-packaging so artifact collection stays exact. A version
bump keeps the environment and discards the build artifacts.

Explicit selectors disagreeing with the adopted session are an
error, never a silent environment switch. Failures and Ctrl+C keep
the session (the interrupt hook unmounts but preserves the tree); a
success consumes it unless --keep. Concurrent same-identity builds
serialize on a lock file kept OUTSIDE the session root: teardown
removes the root while holding the lock, and a lock inside it would
be deleted under the holder, letting the next opener lock a fresh
inode. The apt phases rerun on resume (idempotent, seconds-cheap)
rather than being stamp-gated.
This commit is contained in:
2026-09-26 11:51:06 +02:00
parent 0f1446ad4d
commit 8ff423ffb8
4 changed files with 2136 additions and 47 deletions
+272 -11
View File
@@ -3,6 +3,9 @@ mod cross;
/// cleanup-hook registry drained by the SIGINT handler
pub(crate) mod ephemeral;
mod local;
/// Resumable build sessions: manifest, discovery, `pkh deb list` and the
/// host-tree snapshot/sync that keeps build artifacts alive across attempts
pub mod session;
use crate::context::{self, Context};
use crate::logfmt::{
@@ -115,10 +118,33 @@ pub struct DebBuildOptions<'a> {
pub jobs: Option<usize>,
/// Explicit build context; defaults to the current context.
pub ctx: Option<Arc<Context>>,
/// Resumable-session request: adopt a previous attempt's session
/// (`pkh deb --resume [<id>]`). Defaults to a fresh build, which
/// replaces the session of this identity without reading it.
pub resume: SessionResume,
/// Keep the session after a successful build (`pkh deb --keep`).
/// Without it a success consumes the session, as today's successful
/// build removes its chroot.
pub keep_session: bool,
/// Where build events (phases, progress, outcome) are reported.
pub view: &'a dyn BuildView,
}
/// Which session a `pkh deb` build should adopt, if any.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SessionResume {
/// No adoption: build from scratch, replacing the session of this
/// identity (recording stays on).
#[default]
Fresh,
/// Adopt the newest usable session recorded from this tree
/// (`pkh deb --resume`).
Latest,
/// Adopt the session with this id or unique id prefix
/// (`pkh deb --resume <id>`).
Id(String),
}
impl Default for DebBuildOptions<'_> {
fn default() -> Self {
static QUIET: crate::report::Quiet = crate::report::Quiet;
@@ -133,6 +159,8 @@ impl Default for DebBuildOptions<'_> {
inject: Vec::new(),
jobs: None,
ctx: None,
resume: SessionResume::Fresh,
keep_session: false,
view: &QUIET,
}
}
@@ -175,6 +203,8 @@ async fn build_binary_package_impl(
ref inject,
ref jobs,
ref ctx,
ref resume,
keep_session,
view,
} = opts;
let cwd = cwd.as_deref().unwrap_or_else(|| Path::new("."));
@@ -233,7 +263,50 @@ async fn build_binary_package_impl(
// Create an ephemeral unshare context for all Local builds. It is kept in
// this scope so it outlives the guarded section below and is only dropped
// once the live view has been cleared.
let mut guard = if *mode == BuildMode::Local {
//
// Resumable build sessions are a local-only feature: with a local base
// context the chroot lives in a session root (recording always on,
// adoption only when requested); every other base context keeps the
// historical anonymous temp chroot.
let session_enabled = *mode == BuildMode::Local
&& matches!(base_ctx.config, crate::context::ContextConfig::Local);
let host_tree = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
let identity = session::SessionIdentity {
package: package.clone(),
series: series.to_string(),
arch: arch.to_string(),
cross,
};
let session = if session_enabled {
match resolve_session(resume, &host_tree, &identity, &version, series, chroot_arch) {
Ok(session) => session,
Err(e) => {
view.finish_failure();
return Err(e);
}
}
} else {
None
};
let resume_chroot = session
.as_ref()
.is_some_and(|s| s.chroot_is_usable() && resume != &SessionResume::Fresh);
let mut guard = if let Some(s) = session.clone() {
Some(
ephemeral::EphemeralContextGuard::new_for_session(
s,
series,
chroot_arch,
base_ctx.clone(),
view,
resume_chroot,
keep_session,
)
.await?,
)
} else if *mode == BuildMode::Local {
Some(
ephemeral::EphemeralContextGuard::new_with_context(
series,
@@ -258,16 +331,71 @@ async fn build_binary_package_impl(
};
let result = async {
// Prepare build directory
let build_root = build_ctx.create_temp_dir()?;
// Prepare build directory: the stable /tmp/pkh-build for session
// builds (see EphemeralContextGuard::build_root — the overlay
// upperdir mirrors paths relative to the merge point), a fresh
// timestamped temp dir otherwise.
let build_root = match guard.as_ref() {
Some(g) => g.build_root()?,
None => build_ctx.create_temp_dir()?,
};
// Ensure availability of all needed files for the build
let parent_dir = cwd.parent().ok_or("Cannot find parent directory")?;
build_ctx.ensure_available(parent_dir, &build_root)?;
let parent_dir_name = parent_dir
.file_name()
.ok_or("Cannot find parent directory name")?;
let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap());
// Ensure availability of all needed files for the build. Session
// builds sync the persistent overlay upperdir with the host tree
// first (propagating host-side deletions and modifications into
// the upperdir) and re-mount it, so the previous attempt's build
// artifacts (object files) are visible to make; a cold staging
// (no snapshot, or no overlay support) stages a fresh copy.
let parent_dir = host_tree
.parent()
.ok_or("Cannot find parent directory")?
.to_path_buf();
let mut build_resume = false;
if let Some(s) = &session {
build_resume = session::sync_upper_for_resume(s, &parent_dir)?;
if build_resume {
log::info!(
"Reusing the build artifacts of the previous attempt (incremental build)"
);
}
}
let staged_path = if let Some(s) = &session {
// Re-mount the session's persistent upperdir over the host
// tree; a driver without overlay support (or a failed mount)
// falls back to a fresh copy — still environment-resumable,
// just not artifact-resumable.
let staging = match build_ctx.ensure_available_with_overlay(
&parent_dir,
&build_root,
&s.upper_dir(),
&s.work_dir(),
) {
Ok(staging) => staging,
Err(e) if e.kind() == std::io::ErrorKind::Unsupported => {
let path = build_ctx.ensure_available(&parent_dir, &build_root)?;
crate::context::OverlayStaging {
path,
overlay: false,
}
}
Err(e) => return Err(e.into()),
};
if !staging.overlay {
// Copy fallback: the staged tree is fresh, there is nothing
// incremental to build on.
build_resume = false;
}
staging.path
} else {
build_ctx.ensure_available(&parent_dir, &build_root)?
};
// The staged parent (build root + workspace name) is where the
// build steps and the artifact collection work.
let build_root = staged_path.to_string_lossy().into_owned();
if let Some(s) = &session {
s.set_build_resume(build_resume);
}
// Resolve the package directory inside the staging area. The tree
// the caller pointed at is authoritative (its changelog defined the
@@ -302,6 +430,8 @@ async fn build_binary_package_impl(
build_ctx.clone(),
view,
*jobs,
session.as_ref(),
resume_chroot,
)
.await?
}
@@ -333,16 +463,147 @@ async fn build_binary_package_impl(
// the chroot) inherit the terminal, so they must not fight the widget.
view.suspend();
// Mark build as successful to trigger chroot cleanup
// Mark build as successful to trigger chroot cleanup. The guard's drop
// disposes of the session: removed on a plain success, kept with
// `--keep` (outcome recorded there).
if result.is_ok()
&& let Some(ref mut g) = guard
{
g.mark_build_successful();
}
// Failures keep the session for a later `--resume`: record the outcome.
if result.is_err()
&& let Some(s) = &session
{
s.set_outcome(session::OUTCOME_FAILED);
}
result
}
/// Resolve the session a build runs in.
///
/// - [`SessionResume::Fresh`]: open (or create) the session of this
/// identity and reset it for a fresh attempt — the previous attempt's
/// content is replaced without being read. When a concurrent build holds
/// the session, build without one (the historical anonymous temp chroot).
/// - [`SessionResume::Latest`]: adopt the newest usable session recorded
/// from this tree whose identity matches this build; sessions for other
/// identities are a conflict (refused, never silently switched).
/// - [`SessionResume::Id`]: adopt the session matching the id (or unique
/// prefix) recorded from this tree.
///
/// Returns the session handle, if any; `Ok(None, ..)` means "build without
/// a session". The bool says whether the previous attempt's chroot may be
/// reused (it passed the integrity probe and the bootstrap tarball still
/// matches).
fn resolve_session(
resume: &SessionResume,
host_tree: &Path,
identity: &session::SessionIdentity,
version: &str,
series: &str,
chroot_arch: Option<&str>,
) -> Result<Option<session::Session>, Box<dyn Error>> {
let usable = |s: &session::Session| {
s.chroot_is_usable() && session::tarball_matches(s, series, chroot_arch)
};
match resume {
SessionResume::Fresh => match session::fresh_session(host_tree, identity, version) {
Ok(s) => Ok(Some(s)),
Err(session::SessionOpenError::Busy(busy)) => {
log::info!("{busy}; building without a session");
Ok(None)
}
Err(session::SessionOpenError::Other(e)) => Err(e.into()),
},
SessionResume::Latest => {
let candidates: Vec<session::Session> = session::for_tree(host_tree)
.into_iter()
.filter(|s| usable(s))
.collect();
match candidates
.iter()
.find(|s| s.manifest().identity == *identity)
{
Some(s) => Ok(Some(adopt_session(s, identity, version)?)),
None if candidates.is_empty() => {
log::info!(
"No resumable build session found for this tree; building from scratch"
);
Ok(None)
}
None => {
let found = candidates
.iter()
.map(|s| s.manifest().identity.to_string())
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"cannot resume: the sessions of this tree were built for {found}, \
but this build targets {identity}. Use `pkh deb list` and \
`pkh deb --resume <id>`, or adjust the selectors"
)
.into())
}
}
}
SessionResume::Id(id) => {
let mut matches: Vec<session::Session> = session::by_id_prefix(id)
.into_iter()
.filter(|s| s.manifest().host_tree == host_tree.to_string_lossy())
.filter(|s| usable(s))
.collect();
match matches.len() {
0 => Err(format!(
"no usable build session matches id '{id}' for this tree \
(see `pkh deb list`)"
)
.into()),
1 => Ok(Some(adopt_session(&matches.remove(0), identity, version)?)),
_ => {
let found = matches
.iter()
.map(|s| s.manifest().id.clone())
.collect::<Vec<_>>()
.join(", ");
Err(format!("ambiguous session id '{id}': matches {found}").into())
}
}
}
}
}
/// Lock a session for adoption and update its journal: outcome resets to
/// running, a changed changelog version drops the build artifacts (the
/// environment is kept), and the reuse decision for the chroot is made.
fn adopt_session(
session: &session::Session,
identity: &session::SessionIdentity,
version: &str,
) -> Result<session::Session, Box<dyn Error>> {
session
.acquire()
.map_err(|busy| -> Box<dyn Error> { format!("cannot resume: {busy}").into() })?;
let manifest = session.manifest();
if manifest.tree_version != version {
log::info!(
"Version changed since the previous attempt ({}): build artifacts \
are discarded, the environment is reused",
manifest.tree_version
);
session.wipe_build_artifacts();
session.set_tree_version(version);
}
session.set_outcome(session::OUTCOME_RUNNING);
log::info!(
"Resuming session {} for {identity}: the prepared chroot and \
installed build dependencies are reused",
manifest.id
);
Ok(session.clone())
}
/// Resolve the package directory for a build inside the staged build root.
///
/// The tree the caller pointed at is authoritative: `cwd`'s changelog