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:
+272
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user