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
+111 -5
View File
@@ -12,6 +12,7 @@ use std::sync::Arc;
use crate::apt;
use crate::deb::cross;
use crate::deb::session;
use crate::debian::control::ControlInfo;
use crate::debian::deps::{Deps, Facts, ParseOpts, PkgRelation};
@@ -26,6 +27,37 @@ fn cap<'a>(
cmd
}
/// The session journal stamps this build works with: the input stamps of
/// the patch and build-dependency phases, computed from the host tree and
/// the resolved selectors.
struct JournalStamps {
patches: Option<String>,
control: Option<String>,
}
/// Compute the journal stamps for a session build; `None` without one.
fn journal_stamps(
session: &session::Session,
arch: &str,
series: &str,
pocket: Option<&str>,
cross: bool,
ppa: &[String],
inject_packages: &[String],
) -> JournalStamps {
let host_tree = PathBuf::from(session.manifest().host_tree);
let selectors = format!(
"{arch}\n{cross}\n{series}\n{}\n{}\n{}",
pocket.unwrap_or(""),
ppa.join(","),
inject_packages.join(",")
);
JournalStamps {
patches: session::patches_stamp(&host_tree),
control: session::control_stamp(&host_tree, &selectors),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn build(
package: &str,
@@ -41,9 +73,15 @@ pub async fn build(
ctx: Arc<Context>,
view: &dyn BuildView,
jobs: Option<usize>,
session: Option<&session::Session>,
resume: bool,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let sink: Option<Arc<dyn LineSink>> = view.sink();
// Journal stamps for a session build; recording is a no-op without one.
let stamps =
session.map(|s| journal_stamps(s, arch, series, pocket, cross, ppa, inject_packages));
// Environment
let mut env = HashMap::<String, String>::new();
env.insert("LANG".to_string(), "C".to_string());
@@ -207,6 +245,9 @@ pub async fn build(
or re-run with RUST_LOG=debug for more details."
.into());
}
if let Some(s) = session {
s.record_phase("apt_update", None);
}
// Install essential packages
log::debug!("Installing essential packages for local build...");
@@ -231,6 +272,9 @@ pub async fn build(
if !status.success() {
return Err("Could not install essential packages for the build".into());
}
if let Some(s) = session {
s.record_phase("essentials", None);
}
// The package directory was resolved by the caller (the staged copy of
// the tree the user pointed at, or the name-pattern search fallback)
@@ -249,8 +293,23 @@ pub async fn build(
Err(e) => log::debug!("cannot read changelog for SOURCE_DATE_EPOCH: {}", e),
}
// Apply quilt patches if the package provides a patch series
apply_quilt_patches(package_dir_str, &env, ctx.clone(), view, &sink)?;
// Apply quilt patches if the package provides a patch series. On a
// resumed session the previous attempt's patches may still be applied:
// pop them first when the patch tree changed since.
let patch_stamp = stamps.as_ref().and_then(|s| s.patches.clone());
let pop_patches = resume && {
let previous = session
.and_then(|s| s.manifest().phases.get("patches").map(|p| p.stamp.clone()))
.unwrap_or(None);
let applied = ctx
.exists(&Path::new(package_dir_str).join(".pc/applied-patches"))
.unwrap_or(false);
applied && previous != patch_stamp
};
apply_quilt_patches(package_dir_str, &env, ctx.clone(), view, &sink, pop_patches)?;
if let Some(s) = session {
s.record_phase("patches", patch_stamp);
}
// Install injected packages if specified
if !inject_packages.is_empty() {
@@ -274,6 +333,12 @@ pub async fn build(
view,
&sink,
)?;
if let Some(s) = session {
s.record_phase(
"build_deps",
stamps.as_ref().and_then(|st| st.control.clone()),
);
}
// Run the build step
log::debug!("Building (debian/rules build) package...");
@@ -289,6 +354,20 @@ pub async fn build(
if !status.success() {
return Err("Error while building the package".into());
}
if let Some(s) = session {
s.record_phase("build", None);
}
// On a resumed session the previous attempt's debian/files would make
// the artifact collection below surface its outputs: only the new
// attempt's registration may remain.
if resume {
let _ = ctx
.command("rm")
.arg("-f")
.arg(Path::new(package_dir_str).join("debian/files"))
.status();
}
// Run the 'binary' step to produce deb
enter_phase(view, Phase::ProducingBinaries);
@@ -334,6 +413,10 @@ pub async fn build(
}
}
if let Some(s) = session {
s.record_phase("binary", None);
}
Ok(artifacts)
}
@@ -843,13 +926,15 @@ fn generate_upload_metadata(
}
/// Apply quilt patches before building, if the package provides a
/// 'debian/patches/series' file
/// 'debian/patches/series' file. `pop_first` un-applies the previous
/// attempt's patches first (resumed session whose patch tree changed).
fn apply_quilt_patches(
package_dir: &str,
env: &HashMap<String, String>,
ctx: Arc<Context>,
view: &dyn BuildView,
sink: &Option<Arc<dyn LineSink>>,
pop_first: bool,
) -> Result<(), Box<dyn Error>> {
let series_path = Path::new(package_dir).join("debian/patches/series");
if !ctx.exists(&series_path)? {
@@ -907,13 +992,33 @@ fn apply_quilt_patches(
return Err("Could not install 'quilt', required to apply patches".into());
}
let mut patch_env = env.clone();
patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string());
// Un-apply the previous attempt's patches when the patch tree changed:
// best-effort (a failing pop leaves the state quilt reports; the fresh
// `push -a` below then fails loudly on the actual problem).
if pop_first {
log::info!("Patch series changed since the previous attempt, un-applying patches");
let status = cap(
ctx.command("quilt")
.current_dir(package_dir)
.envs(patch_env.clone())
.arg("pop")
.arg("-a"),
sink,
)
.status()?;
if !status.success() {
warn!("'quilt pop -a' failed; continuing with 'quilt push -a'");
}
}
// Apply all patches listed in the series
view.phase(
Phase::ApplyingPatches.label(),
Box::new(QuiltClassifier::new(total_patches)),
);
let mut patch_env = env.clone();
patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string());
let status = cap(
ctx.command("quilt")
.current_dir(package_dir)
@@ -1395,6 +1500,7 @@ mod tests {
ctx,
&crate::report::Quiet,
&None,
false,
)
.unwrap();
}