deb: rework build sessions into immutable per-build records

The single mutable session per identity loses the last-good state: a
failed run after a good one adopts the session in place and leaves only
the failed state behind, so "continue from the last success" was
impossible. Each build now records an immutable record under
/var/tmp/pkh/sessions/<slug>/<id>/ (chroot, tree-upper, journal,
manifest), and --incremental forks a NEW record — a full copy of the
base's chroot and, at the same changelog version, its staged artifacts
— under the base's flock, never mutating the base itself.

Selection picks the newest successful record of the identity among the
last five usable, falling back to the newest when none succeeded, and
--incremental <id> pins an exact record (failed ones allowed: their
environment may be fine). The lifecycle is strict keep-iff-incremental:
plain runs build in a throwaway record discarded on success, failure
and interrupt alike (the SIGINT cleanup hook follows the same policy),
while --incremental records are always kept. Each identity keeps its
newest five records; pkh prune's 7-day sweep and orphaned-lock cleanup
follow the nested layout.

--resume and --keep are removed (no aliases): their adoption-in-place
and keep-on-success semantics are exactly what the record model
replaces. pkh deb list shows id, outcome, tree version, fork lineage
and age. README rewritten around the record workflow.
This commit is contained in:
2026-09-29 12:56:29 +02:00
parent d65d3264f4
commit 81f0d52f85
6 changed files with 1736 additions and 516 deletions
+32 -30
View File
@@ -120,43 +120,45 @@ Without a version tag, that search is capped at the last 500 commits
(`--limit <n>`, `0` = unlimited): beyond the cap, the entry simply starts
at the oldest commit searched, which keeps the run fast on large histories.
### Incremental builds (build sessions)
### Incremental builds (build records)
Every `pkh deb` build records a **session** under `/var/tmp/pkh/sessions`:
the bootstrapped chroot, the installed build dependencies and the build
artifacts of the staged tree. When a build fails (or is interrupted), the
session is kept and can be resumed:
Every `pkh deb` build runs in its own immutable **record** under
`/var/tmp/pkh/sessions/<target>/<id>/`: the bootstrapped chroot, the
installed build dependencies and the build artifacts of the staged
tree. `--incremental` forks a previous record — a full copy, the base
is never modified — so a failed run can never destroy the last-good
environment it was built from:
```
pkh deb # fails after 25 minutes
pkh deb --resume # reuses the chroot, build deps and objects;
# only what changed is recompiled
pkh deb list # the sessions of this tree, with their ids
pkh deb --resume <id> # resume a specific session
pkh deb --incremental # one-flag iterate loop: adopts the newest
# session like `--resume` and keeps it on
# success (with `--resume <id>`, only adds keep)
pkh deb --keep # keep the session even after a successful build
# (iterate: edit, `pkh deb --resume --keep`, ...)
pkh prune # garbage-collect old sessions (7-day retention)
pkh deb # fully ephemeral: nothing is kept, even on
# failure or interrupt
pkh deb --incremental # fork the newest successful record of the
# target (last five usable): the chroot, build
# deps and, at the same version, the objects
# are reused; only what changed is recompiled.
# The result is always kept (success or
# failure) for the next fork
pkh deb --incremental <id>
# fork that exact record (see `pkh deb list`);
# a failed record is fine if its environment is
pkh deb list # the records of this tree: id, outcome,
# version, fork lineage, age
pkh prune # garbage-collect records (7-day retention)
```
A plain `pkh deb` never reuses a session — everything is rechecked from
scratch — and it replaces the session of its target. `pkh deb --resume`
refuses to adopt a session built for a different series/architecture.
`pkh deb --incremental` combines `--resume` (without an ID) with
`--keep`, so iterating is a single flag: each successful build keeps the
session and the next `--incremental` build reuses it, recompiling only
what changed. With an explicit `--resume <id>` it only adds keep. A plain
`pkh deb` still never reuses a session and consumes it on success.
Each target (series/arch) keeps its last five records — older ones are
pruned as new ones are created, and `pkh prune` removes those untouched
for a week. `pkh deb --incremental` never forks a record built for a
different series/architecture, and a plain `pkh deb` still never
reuses anything: it builds in a throwaway record that is discarded on
success, failure and interrupt alike.
Packages whose `debian/rules` keeps an input-untracked stamp cache (the
kernels' `debian/stamps`) declare it in the quirks data (`resume_clear`):
resumed builds drop the cache so the wrapper steps — including the
kernel's flavour config export — re-run over the incremental inner
builds, and a config edit is never silently ignored. If a build died
hard (OOM kill), the next build unmounts and clears the leftover session
kernels' `debian/stamps`) declare it in the quirks data
(`resume_clear`): forked builds drop the cache so the wrapper steps —
including the kernel's flavour config export — re-run over the
incremental inner builds, and a config edit is never silently ignored.
If a build died hard (OOM kill), the next fork unmounts the leftover
state first (escalating through sudo when needed).
### Running the DEP-8 tests
+79 -63
View File
@@ -22,10 +22,12 @@ use xz2::read::XzDecoder;
// /proc and any overlayfs mounts.
// ---------------------------------------------------------------------------
/// Interrupt-time teardown of a chroot tree. When `keep_tree` is set the
/// tree itself is left in place after unmounting (a resumable build
/// session: the interrupt is a pause, not a discard); otherwise this is
/// the historical full cleanup.
/// Interrupt-time teardown of a build tree. When `keep_tree` is set the
/// tree itself is left in place after unmounting (an `--incremental`
/// record: the interrupt is a pause, not a discard); otherwise this is
/// the historical full cleanup. For a plain `pkh deb` the `path` is the
/// whole throwaway record root, so the record is removed — the record
/// model's keep-iff-incremental policy applies to interrupts too.
///
/// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go
/// through the context manager, the ephemeral context's driver (whose
@@ -64,7 +66,7 @@ fn sigint_cleanup_chroot(chroot_path: &Path, keep_tree: bool) {
if keep_tree {
log::debug!(
"Interrupted build session kept at {} (resume with `pkh deb --resume`)",
"Interrupted build record kept at {} (fork it with `pkh deb --incremental`)",
chroot_path.display()
);
return;
@@ -129,8 +131,17 @@ pub(crate) fn privileged_command(program: &str, is_root: bool) -> Command {
}
/// Privileged `rm -rf` of a tree (residual chroots hold root-owned device
/// nodes); shared with the session removal in [`crate::deb::session`].
/// nodes); shared with the record removal in [`crate::deb::session`].
///
/// A tree the current user can fully remove needs no escalation, so the
/// plain removal runs first (record forks and discarded records carry
/// user-owned state); escalation only happens when it fails.
pub(crate) fn privileged_remove(path: &Path) -> std::io::Result<()> {
match fs::remove_dir_all(path) {
Ok(()) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(_) => {}
}
let is_root = crate::utils::root::is_root().unwrap_or(false);
let status = privileged_command("rm", is_root)
.arg("-rf")
@@ -294,10 +305,10 @@ fn unescape_mount_field(field: &str) -> String {
/// for building packages with unshare permissions.
///
/// In session mode ([`EphemeralContextGuard::new_for_session`]) the chroot
/// lives inside a [`Session`]: a previous attempt's chroot is reused when
/// the caller verified it, teardown keeps the tree for a later `--resume`,
/// and a successful build removes the session unless `keep_on_success` is
/// set.
/// lives inside a [`Session`] record: a forked record's chroot is reused
/// when the caller verified it, and the keep policy is strict — the record
/// is kept exactly for `--incremental` runs (`persist_record`), discarded
/// for plain ones whatever the outcome.
pub struct EphemeralContextGuard {
/// The ephemeral build context this guard created (an unshare context
/// bound to the chroot, parented on the base context). Held explicitly so
@@ -314,10 +325,10 @@ pub struct EphemeralContextGuard {
/// Registration of the interrupt-time cleanup hook; deregistered when
/// this guard drops, so the hook can never fire after the normal cleanup
cleanup_hook: Option<CleanupHookGuard>,
/// The resumable build session this guard works in, when any.
/// The build record this guard works in, when any.
session: Option<Session>,
/// Whether a successful build keeps the session (`pkh deb --keep`).
keep_on_success: bool,
/// Whether the record is kept after the build (`pkh deb --incremental`).
persist_record: bool,
}
impl EphemeralContextGuard {
@@ -340,17 +351,18 @@ impl EphemeralContextGuard {
/// Create the ephemeral build context inside `session`'s chroot.
///
/// # Arguments
/// * `session` - The resumable session owning the chroot (locked by the
/// * `session` - The build record owning the chroot (locked by the
/// caller)
/// * `series` - The distribution series (e.g., "noble", "sid")
/// * `arch` - Optional target architecture, as for
/// [`EphemeralContextGuard::new_with_context`]
/// * `base_ctx` - The base context to use for chroot work
/// * `reuse_chroot` - Adopt the previous attempt's bootstrapped chroot
/// * `reuse_chroot` - Reuse the forked record's bootstrapped chroot
/// instead of re-extracting the tarball; the caller must only set
/// this after the session's chroot passed the integrity probe
/// * `keep_on_success` - Keep the session after a successful build
/// (`pkh deb --keep`)
/// this after the record's chroot passed the integrity probe
/// * `persist_record` - Keep the record after the build
/// (`pkh deb --incremental`); a plain run's record is discarded
/// whatever the outcome
pub async fn new_for_session(
session: Session,
series: &str,
@@ -358,7 +370,7 @@ impl EphemeralContextGuard {
base_ctx: Arc<Context>,
view: &dyn BuildView,
reuse_chroot: bool,
keep_on_success: bool,
persist_record: bool,
) -> Result<Self, Box<dyn Error>> {
Self::new_internal(
Some(session),
@@ -367,7 +379,7 @@ impl EphemeralContextGuard {
base_ctx,
view,
reuse_chroot,
keep_on_success,
persist_record,
)
.await
}
@@ -379,7 +391,7 @@ impl EphemeralContextGuard {
base_ctx: Arc<Context>,
view: &dyn BuildView,
reuse_chroot: bool,
keep_on_success: bool,
persist_record: bool,
) -> Result<Self, Box<dyn Error>> {
// Save the globally-installed context so Drop can restore exactly
// this handle: concurrent builds install their own ephemeral
@@ -387,7 +399,7 @@ impl EphemeralContextGuard {
// before this guard swapped anything in.
let previous_context = context::current();
// In session mode the chroot lives inside the session root (and is
// In session mode the chroot lives inside the record root (and is
// reused when the caller says so); otherwise it is a fresh temp dir.
let chroot_path = match &session {
Some(s) => {
@@ -412,14 +424,21 @@ impl EphemeralContextGuard {
// base context: the hook must be self-contained (stored path +
// direct umount/rm subprocesses) and cannot go through `base_ctx`.
// For remote or nested bases the chroot lives elsewhere, and
// leftovers stay handled by `pkh prune` as before. A session tree is
// kept on interrupt (a pause, not a discard); a plain temp chroot is
// still removed.
// leftovers stay handled by `pkh prune` as before. An
// `--incremental` record is kept on interrupt (a pause, not a
// discard); a plain run's whole record root and a plain temp chroot
// are removed.
let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) {
Some(crate::interrupt::register_cleanup_hook(Box::new({
let chroot_path = chroot_path.clone();
let keep_tree = session.is_some();
move || sigint_cleanup_chroot(&chroot_path, keep_tree)
// Session mode tears the whole record root down (the
// manifest is part of the record); plain temp chroots
// only ever had the chroot dir.
let cleanup_path = match &session {
Some(s) => s.root().to_path_buf(),
None => chroot_path.clone(),
};
let keep_tree = persist_record;
move || sigint_cleanup_chroot(&cleanup_path, keep_tree)
})))
} else {
log::debug!(
@@ -429,7 +448,7 @@ impl EphemeralContextGuard {
None
};
// Download and extract the chroot tarball (or reuse the session's
// Download and extract the chroot tarball (or reuse the record's
// prepared chroot)
if let Err(e) = Self::download_and_extract_chroot(
series,
@@ -483,7 +502,7 @@ impl EphemeralContextGuard {
base_ctx,
cleanup_hook,
session,
keep_on_success,
persist_record,
})
}
@@ -506,19 +525,19 @@ impl EphemeralContextGuard {
session: Option<&Session>,
reuse_chroot: bool,
) -> Result<(), Box<dyn Error>> {
// Session resume: the previous attempt's chroot passed the caller's
// checks — skip the tarball extraction entirely, the environment
// (apt lists, installed build-deps) is still in there.
// Record fork: the base's chroot passed the caller's checks —
// skip the tarball extraction entirely, the environment (apt
// lists, installed build-deps) is still in there.
if let Some(s) = session {
if reuse_chroot && s.chroot_is_usable() {
log::info!(
"Reusing the prepared chroot of session {} (bootstrap skipped)",
"Reusing the prepared chroot of record {} (bootstrap skipped)",
s.manifest().id
);
Self::finalize_chroot(chroot_path, ctx, view)?;
return Ok(());
}
// Not reusing (fresh session or failed probe): never extract
// Not reusing (fresh record or failed probe): never extract
// over stale content.
clear_dir(chroot_path)?;
}
@@ -600,8 +619,8 @@ impl EphemeralContextGuard {
// Create device nodes in the chroot and bind-mount /proc
Self::finalize_chroot(chroot_path, ctx_for_devices, view)?;
// Record the bootstrap in the session: marker file (the resume
// integrity probe) and the tarball the chroot came from.
// Record the bootstrap in the record: marker file (the integrity
// probe) and the tarball the chroot came from.
if let Some(s) = session {
std::fs::write(chroot_path.join(".pkh-ready"), b"")?;
let metadata = fs::metadata(&tarball_path)?;
@@ -619,7 +638,7 @@ impl EphemeralContextGuard {
tarball_sha256,
ready: true,
});
log::debug!("Session {} chroot is ready", s.manifest().id);
log::debug!("Record {} chroot is ready", s.manifest().id);
}
Ok(())
@@ -864,7 +883,7 @@ impl EphemeralContextGuard {
}
/// The staged build root inside the chroot: the stable
/// `/tmp/pkh-build` for session builds (the overlay upperdir mirrors
/// `/tmp/pkh-build` for record builds (the overlay upperdir mirrors
/// paths relative to the merge point, so the staged tree must live at
/// the same in-chroot path on every attempt for the build artifacts to
/// be reusable), a fresh timestamped temp dir otherwise.
@@ -923,10 +942,11 @@ impl Drop for EphemeralContextGuard {
// touches it.
context::manager().set_current_ephemeral(self.previous_context.clone());
// Session mode: the tree is a resumable build session. The /proc
// bind mount must come down in every case (a kept session has to be
// unmount-clean for the next resume); the tree itself stays unless
// a successful build without `--keep` consumes it.
// Session mode: the tree is a build record. The /proc bind mount
// must come down in every case (a kept record has to be
// unmount-clean for the next fork); the record itself follows the
// keep policy — kept exactly for `--incremental` runs, discarded
// for plain ones whatever the outcome.
if let Some(session) = &self.session {
let proc_path = self.chroot_path.join("proc");
let is_root = crate::utils::root::is_root().unwrap_or(false);
@@ -940,26 +960,7 @@ impl Drop for EphemeralContextGuard {
.status()
};
if self.build_succeeded {
if self.keep_on_success {
session.set_outcome(session::OUTCOME_SUCCESS);
log::info!(
"Build session {} kept (resume with `pkh deb --resume`)",
session.manifest().id
);
} else {
log::debug!(
"Build succeeded, removing build session {}",
session.root().display()
);
session.remove();
}
} else {
log::debug!(
"Build did not succeed: session {} kept (resume with `pkh deb --resume`)",
session.manifest().id
);
}
session.dispose_after_build(self.persist_record, self.build_succeeded);
return;
}
@@ -1085,6 +1086,21 @@ mod chroot_cleanup_tests {
assert!(!chroot.exists());
}
/// The interrupt teardown of a kept tree (an `--incremental` record)
/// unmounts and leaves the tree in place — the pause, not the discard.
#[test]
fn sigint_cleanup_keeps_the_tree_when_asked() {
let dir = tempfile::tempdir().unwrap();
let record_root = dir.path().join("record");
std::fs::create_dir_all(record_root.join("chroot/rootfs")).unwrap();
std::fs::write(record_root.join("session.json"), "{}").unwrap();
sigint_cleanup_chroot(&record_root, true);
assert!(record_root.join("chroot/rootfs").exists());
assert!(record_root.join("session.json").is_file());
}
/// clear_dir removes content a plain `rm -rf` cannot: a root-owned
/// 0700 directory (the shape of an overlay workdir left by a crashed
/// build). Skipped when neither root nor working non-interactive sudo
+424 -127
View File
@@ -118,30 +118,34 @@ 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,
/// Incremental request (`pkh deb --incremental [<id>]`): which
/// record to fork as the base of this build, and whether this
/// build's own record is kept. Defaults to a plain throwaway run.
pub incremental: Incremental,
/// Where build events (phases, progress, outcome) are reported.
pub view: &'a dyn BuildView,
}
/// Which session a `pkh deb` build should adopt, if any.
/// The `--incremental` request of a `pkh deb` build: which record the
/// build forks its environment from, and whether its own record is
/// kept afterwards.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SessionResume {
/// No adoption: build from scratch, replacing the session of this
/// identity (recording stays on).
pub enum Incremental {
/// A plain run (`pkh deb` without `--incremental`): build from
/// scratch in a throwaway record, discarded on success, failure
/// and interrupt alike.
#[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>`).
Off,
/// `--incremental`: fork the auto-selected base — the newest
/// successful record of this identity among the last five usable —
/// and always keep the new record. With no usable candidate the
/// build runs from scratch and its record becomes the identity's
/// first base.
Auto,
/// `--incremental <id>`: fork that exact record (unique id or
/// prefix, restricted to the records of this host tree; a FAILED
/// record is a valid base — its environment may be fine, and the
/// user pinned it deliberately) and always keep the new record.
Id(String),
}
@@ -159,8 +163,7 @@ impl Default for DebBuildOptions<'_> {
inject: Vec::new(),
jobs: None,
ctx: None,
resume: SessionResume::Fresh,
keep_session: false,
incremental: Incremental::Off,
view: &QUIET,
}
}
@@ -203,8 +206,7 @@ async fn build_binary_package_impl(
ref inject,
ref jobs,
ref ctx,
ref resume,
keep_session,
ref incremental,
view,
} = opts;
let cwd = cwd.as_deref().unwrap_or_else(|| Path::new("."));
@@ -264,10 +266,11 @@ async fn build_binary_package_impl(
// this scope so it outlives the guarded section below and is only dropped
// once the live view has been cleared.
//
// 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.
// Build records are a local-only feature: with a local base context
// every build runs in its own record under the sessions directory
// (forked from a previous one with `--incremental`, thrown away
// otherwise); 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());
@@ -278,9 +281,20 @@ async fn build_binary_package_impl(
cross,
};
let mut persist_record = false;
let session = if session_enabled {
match resolve_session(resume, &host_tree, &identity, &version, series, chroot_arch) {
Ok(session) => session,
match resolve_record(
incremental,
&host_tree,
&identity,
&version,
series,
chroot_arch,
) {
Ok((record, persist)) => {
persist_record = persist;
Some(record)
}
Err(e) => {
view.finish_failure();
return Err(e);
@@ -289,9 +303,9 @@ async fn build_binary_package_impl(
} else {
None
};
let resume_chroot = session
.as_ref()
.is_some_and(|s| s.chroot_is_usable() && resume != &SessionResume::Fresh);
// Only a forked record carries a bootstrapped chroot at this point;
// fresh ones (plain runs and first `--incremental` runs) extract it.
let resume_chroot = session.as_ref().is_some_and(|s| s.chroot_is_usable());
let mut guard = if let Some(s) = session.clone() {
Some(
@@ -302,7 +316,7 @@ async fn build_binary_package_impl(
base_ctx.clone(),
view,
resume_chroot,
keep_session,
persist_record,
)
.await?,
)
@@ -472,16 +486,19 @@ 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. The guard's drop
// disposes of the session: removed on a plain success, kept with
// `--keep` (outcome recorded there).
// Mark the build as successful for the guard's drop, which disposes of
// the record per the keep policy: an `--incremental` record is kept
// with its outcome recorded, a plain run's throwaway record is
// always discarded.
if result.is_ok()
&& let Some(ref mut g) = guard
&& let Some(g) = &mut guard
{
g.mark_build_successful();
}
// Failures keep the session for a later `--resume`: record the outcome.
// A failed `--incremental` run keeps its record for a later fork:
// record the outcome (discarded plain runs need none).
if result.is_err()
&& persist_record
&& let Some(s) = &session
{
s.set_outcome(session::OUTCOME_FAILED);
@@ -490,133 +507,134 @@ async fn build_binary_package_impl(
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,
/// Resolve the record a build runs in, per the `--incremental` request,
/// against the real sessions root and the chroot tarball the bootstrap
/// would use today. See [`resolve_record_in`].
fn resolve_record(
incremental: &Incremental,
host_tree: &Path,
identity: &session::SessionIdentity,
version: &str,
series: &str,
chroot_arch: Option<&str>,
) -> Result<Option<session::Session>, Box<dyn Error>> {
) -> Result<(session::Session, bool), Box<dyn Error>> {
let expected = ephemeral::chroot_tarball_path(series, chroot_arch).ok();
resolve_record_in(
&session::sessions_root(),
expected.as_deref(),
incremental,
host_tree,
identity,
version,
)
}
/// The testable core of [`resolve_record`] over an explicit sessions root
/// and bootstrap tarball.
///
/// Every local build runs in its own record: [`Incremental::Off`] gets a
/// brand-new throwaway one, [`Incremental::Auto`] and
/// [`Incremental::Id`] fork the selected base. The returned flag says
/// whether the record is kept after the build — exactly the
/// `--incremental` runs keep theirs.
///
/// A base must be usable: its chroot passes the integrity probe and was
/// bootstrapped from the tarball this build would use (`expected_tarball`
/// is `None` when the path cannot be resolved — no record is usable
/// then, which degrades an `--incremental` run to a fresh build).
fn resolve_record_in(
records_root: &Path,
expected_tarball: Option<&Path>,
incremental: &Incremental,
host_tree: &Path,
identity: &session::SessionIdentity,
version: &str,
) -> Result<(session::Session, bool), Box<dyn Error>> {
let usable = |s: &session::Session| {
s.chroot_is_usable() && session::tarball_matches(s, series, chroot_arch)
s.chroot_is_usable()
&& expected_tarball.is_some_and(|t| session::tarball_matches_path(s, t))
};
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::Unusable(e)) => {
log::error!("{e}; 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)
match incremental {
Incremental::Off => Ok((
session::new_record_in(records_root, host_tree, identity, version)?,
false,
)),
Incremental::Auto => {
// A record locked by a live build cannot be a base: its
// chroot may be mid-mutation (apt always runs, deps land
// inside it), and the fork would copy that state.
let candidates: Vec<session::Session> =
session::identity_records_in(records_root, identity)
.into_iter()
.filter(|s| usable(s))
.filter(|s| !session::lock_is_held(&s.lock_path()).unwrap_or(true))
.take(session::KEEP_RECORDS)
.collect();
match candidates
.iter()
.find(|s| s.manifest().identity == *identity)
{
Some(s) => Ok(Some(adopt_session(s, identity, version)?)),
None if candidates.is_empty() => {
for base in session::rank_base_candidates(candidates) {
let why = if base.manifest().last_outcome == session::OUTCOME_SUCCESS {
"the newest successful record of the target"
} else {
"no successful record among the last usable ones; the newest record"
};
match session::fork_record_in(records_root, &base, host_tree, identity, version) {
Ok(record) => {
log::info!(
"No resumable build session found for this tree; building from scratch"
"Base of the incremental build: record {} ({why})",
base.manifest().id
);
Ok(None)
return Ok((record, true));
}
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())
Err(session::SessionOpenError::Busy(busy)) => {
log::info!("{busy}; trying the next candidate record");
}
Err(e) => return Err(e.into()),
}
}
log::info!(
"No usable record of {identity} to fork; building from scratch — \
this run's record becomes the target's first base"
);
Ok((
session::new_record_in(records_root, host_tree, identity, version)?,
true,
))
}
SessionResume::Id(id) => {
let mut matches: Vec<session::Session> = session::by_id_prefix(id)
Incremental::Id(id) => {
let mut matches: Vec<session::Session> = session::by_id_prefix_in(records_root, 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 \
"no usable build record matches id '{id}' for this tree \
(see `pkh deb list`)"
)
.into()),
1 => Ok(Some(adopt_session(&matches.remove(0), identity, version)?)),
1 => {
let base = matches.remove(0);
log::info!(
"Base of the incremental build: record {} (pinned by id)",
base.manifest().id
);
Ok((
session::fork_record_in(records_root, &base, host_tree, identity, version)?,
true,
))
}
_ => {
let found = matches
.iter()
.map(|s| s.manifest().id.clone())
.collect::<Vec<_>>()
.join(", ");
Err(format!("ambiguous session id '{id}': matches {found}").into())
Err(format!("ambiguous record 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
@@ -1330,4 +1348,283 @@ mod tests {
".deb not produced for the 'tree'-named directory, got: {deb_files:?}"
);
}
// -------------------------------------------------------------------
// Record resolution (`--incremental` base selection)
// -------------------------------------------------------------------
use crate::deb::session::record_fixtures::{
record_with_environment, sessions_fixture, test_identity,
};
use crate::deb::session::{self, SessionIdentity};
/// A plain run resolves to a brand-new, lineage-free record that the
/// caller will discard — including when no record exists at all yet.
#[test]
fn resolve_plain_run_builds_a_fresh_discardable_record() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let (record, persist) = resolve_record_in(
root.path(),
Some(&tarball),
&Incremental::Off,
&host_tree,
&identity,
"1.0",
)
.expect("a plain run always resolves to a fresh record");
assert!(!persist, "a plain run discards its record");
assert_eq!(record.manifest().base, None);
assert!(!record.chroot_is_usable());
}
/// The user scenario, end to end over the record layout: record A
/// succeeds, a fork B of it fails — the default `--incremental`
/// selection forks A again (the newest success), never the failed B.
#[test]
fn resolve_auto_forks_the_newest_success_after_a_failed_fork() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let a = record_with_environment(
root.path(),
&tarball,
&host_tree,
&identity,
"1.0",
Some(session::OUTCOME_SUCCESS),
);
let a_id = a.manifest().id;
let b = session::fork_record_in(root.path(), &a, &host_tree, &identity, "1.0").unwrap();
let b_id = b.manifest().id;
b.set_outcome(session::OUTCOME_FAILED);
drop((a, b));
let (record, persist) = resolve_record_in(
root.path(),
Some(&tarball),
&Incremental::Auto,
&host_tree,
&identity,
"1.0",
)
.expect("the auto selection must fork a usable record");
assert!(persist);
assert_eq!(record.manifest().base.as_deref(), Some(a_id.as_str()));
assert_ne!(record.manifest().id, b_id);
}
/// A first `--incremental` run with no candidate builds completely
/// fresh and persists its record — it becomes the identity's first
/// base.
#[test]
fn resolve_auto_without_candidates_builds_fresh_and_persists() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let (record, persist) = resolve_record_in(
root.path(),
Some(&tarball),
&Incremental::Auto,
&host_tree,
&identity,
"1.0",
)
.expect("no candidates must degrade to a fresh build");
assert!(persist, "the first record becomes a base");
assert_eq!(record.manifest().base, None);
}
/// Non-usable records never make it into the selection: a newer
/// successful record with a broken chroot loses to an older usable
/// failed one.
#[test]
fn resolve_auto_skips_non_usable_records() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let usable = record_with_environment(
root.path(),
&tarball,
&host_tree,
&identity,
"1.0",
Some(session::OUTCOME_FAILED),
);
let usable_id = usable.manifest().id;
drop(usable);
// Newer, successful, but its chroot never finished bootstrapping.
let broken = session::new_record_in(root.path(), &host_tree, &identity, "1.0").unwrap();
broken.set_outcome(session::OUTCOME_SUCCESS);
drop(broken);
let (record, _) = resolve_record_in(
root.path(),
Some(&tarball),
&Incremental::Auto,
&host_tree,
&identity,
"1.0",
)
.expect("the usable record is still a base");
assert_eq!(record.manifest().base.as_deref(), Some(usable_id.as_str()));
}
/// `--incremental <id>` may pin a FAILED record — its environment may
/// be fine and the user chose it deliberately.
#[test]
fn resolve_pinned_id_forks_a_failed_record() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let failed = record_with_environment(
root.path(),
&tarball,
&host_tree,
&identity,
"1.0",
Some(session::OUTCOME_FAILED),
);
let failed_id = failed.manifest().id;
drop(failed);
let (record, persist) = resolve_record_in(
root.path(),
Some(&tarball),
&Incremental::Id(failed_id.clone()),
&host_tree,
&identity,
"1.0",
)
.expect("a pinned failed record is a valid base");
assert!(persist);
assert_eq!(record.manifest().base.as_deref(), Some(failed_id.as_str()));
}
/// A pinned id is restricted to the records of this host tree: an id
/// recorded from another tree is refused.
#[test]
fn resolve_pinned_id_refuses_other_trees() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let other_tree = root.path().join("elsewhere");
let foreign = record_with_environment(
root.path(),
&tarball,
&other_tree,
&identity,
"1.0",
Some(session::OUTCOME_SUCCESS),
);
let foreign_id = foreign.manifest().id;
drop(foreign);
let err = resolve_record_in(
root.path(),
Some(&tarball),
&Incremental::Id(foreign_id),
&host_tree,
&identity,
"1.0",
)
.expect_err("records of other trees must not be forked by id");
assert!(
err.to_string()
.contains("no usable build record matches id"),
"wrong error: {err}"
);
}
/// An ambiguous id prefix is an error naming every match.
#[test]
fn resolve_pinned_id_refuses_ambiguous_prefixes() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
drop(record_with_environment(
root.path(),
&tarball,
&host_tree,
&identity,
"1.0",
None,
));
drop(record_with_environment(
root.path(),
&tarball,
&host_tree,
&identity,
"1.0",
None,
));
// All record ids share the date-time prefix of their creation
// second; anything shorter is ambiguous.
let prefix = now_prefix(root.path(), &identity);
let err = resolve_record_in(
root.path(),
Some(&tarball),
&Incremental::Id(prefix),
&host_tree,
&identity,
"1.0",
)
.expect_err("an ambiguous prefix must be refused");
assert!(
err.to_string().contains("ambiguous record id"),
"wrong error: {err}"
);
}
/// The common date-time prefix of every record of the identity (the
/// timestamp of the test-second they were all created in).
fn now_prefix(root: &std::path::Path, identity: &SessionIdentity) -> String {
session::identity_records_in(root, identity)[0]
.manifest()
.id
.chars()
.take(8)
.collect()
}
/// A pinned record of another identity is refused: its environment
/// was bootstrapped for a different target.
#[test]
fn resolve_pinned_id_refuses_foreign_identities() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let other = SessionIdentity {
arch: "riscv64".to_string(),
..identity.clone()
};
let host_tree = root.path().join("tree");
let foreign = record_with_environment(
root.path(),
&tarball,
&host_tree,
&other,
"1.0",
Some(session::OUTCOME_SUCCESS),
);
let foreign_id = foreign.manifest().id;
drop(foreign);
let err = resolve_record_in(
root.path(),
Some(&tarball),
&Incremental::Id(foreign_id),
&host_tree,
&identity,
"1.0",
)
.expect_err("a foreign-identity record must not be forked");
assert!(
err.to_string().contains("it was recorded for"),
"wrong error: {err}"
);
}
}
+1033 -182
View File
File diff suppressed because it is too large Load Diff
+20 -26
View File
@@ -365,25 +365,21 @@ fn main() {
.long_help("Change build mode [local]\nDefault will chose depending on other parameters, don't provide if unsure"))
.arg(arg!(-j --jobs <jobs> "Number of parallel build jobs (default: number of CPUs available in the build context)").required(false))
.arg(
clap::Arg::new("resume")
.long("resume")
clap::Arg::new("incremental")
.long("incremental")
.value_name("ID")
.num_args(0..=1)
.require_equals(false)
.default_missing_value("")
.help("Resume a previous build session: reuse the chroot, build dependencies and build artifacts")
.long_help("Resume a previous build session of this tree: the prepared chroot and installed build dependencies are reused, and the build continues in the staged tree (make recompiles only what changed).\nWithout a value, the newest session of this tree is adopted; with an ID (as shown by 'pkh deb list'), that exact session.\nA plain build never reuses a session: everything is rechecked from scratch."),
.help("Incremental build: fork a previous build record of this target and keep the result")
.long_help("Incremental build: fork a previous build record of this target — its prepared chroot and installed build dependencies are copied into a new record, and at the same changelog version its build artifacts too (make recompiles only what changed).\nWithout a value, the newest successful record of the target (among the last five usable) is the base; with an ID (as shown by 'pkh deb list'), that exact record — a failed one is fine if its environment is.\nEvery --incremental run keeps its record, success or failure, so the next one can fork it; a plain 'pkh deb' is fully ephemeral (nothing is kept, even on failure or interrupt)."),
)
.arg(arg!(--keep "Keep the build session after a successful build (for a later --resume)")
.long_help("Keep the build session after a successful build, so a later 'pkh deb --resume' can iterate on it (e.g. after editing sources).\nWithout it, a successful build consumes the session (failures and interrupts always keep it)."))
.arg(arg!(--incremental "Reuse a previous build session of this target (like --resume) and keep the session even on success; the next --incremental build reuses the chroot, build deps and objects")
.required(false))
.arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false)
.long_help("Show raw tool output instead of the live build view.\nAlso implied by RUST_LOG=debug for pkh's own logs."))
.subcommand(
Command::new("list")
.about("List the resumable build sessions recorded from this tree")
.long_about("List the resumable build sessions recorded from this tree, one per target (series/arch), with the id to pass to 'pkh deb --resume <id>'.\nRead-only: never builds or removes anything ('pkh prune' removes old sessions)."),
.about("List the build records recorded from this tree")
.long_about("List the build records recorded from this tree, with their outcome, version, fork lineage and the id to pass to 'pkh deb --incremental <id>'.\nRead-only: never builds or removes anything (only the last five records per target are kept, 'pkh prune' removes the ones older than a week)."),
)
)
.subcommand(
@@ -891,9 +887,8 @@ fn main() {
Some(("deb", sub_matches)) => {
let cwd = current_dir_or_exit();
// `pkh deb list`: read-only listing of the resumable build
// sessions recorded from this tree; never touches the build
// machinery.
// `pkh deb list`: read-only listing of the build records
// recorded from this tree; never touches the build machinery.
if let Some(("list", _)) = sub_matches.subcommand() {
let tree = cwd.canonicalize().unwrap_or(cwd.clone());
let rows = pkh::deb::session::list_for_tree(&tree);
@@ -939,18 +934,18 @@ fn main() {
})
});
// Session handling: --resume [<id>], --incremental and --keep
// (see the deb subcommand help). --incremental adopts the newest
// session when no explicit --resume ID is given, and always adds
// keep so the session survives success.
let incremental = sub_matches.get_flag("incremental");
let resume = match sub_matches.get_one::<String>("resume").map(String::as_str) {
Some("") => pkh::deb::SessionResume::Latest,
Some(id) => pkh::deb::SessionResume::Id(id.to_string()),
None if incremental => pkh::deb::SessionResume::Latest,
None => pkh::deb::SessionResume::Fresh,
// Incremental request: `--incremental` (no value) forks the
// auto-selected base, `--incremental <id>` that exact record;
// without the flag the run is fully ephemeral (see the deb
// subcommand help).
let incremental = match sub_matches
.get_one::<String>("incremental")
.map(String::as_str)
{
Some("") => pkh::deb::Incremental::Auto,
Some(id) => pkh::deb::Incremental::Id(id.to_string()),
None => pkh::deb::Incremental::Off,
};
let keep_session = sub_matches.get_flag("keep") || incremental;
// Live build view, unless --verbose (DebUi additionally disables
// itself when stdout is not a terminal)
@@ -976,8 +971,7 @@ fn main() {
ppa,
inject,
jobs,
resume,
keep_session,
incremental,
view,
..Default::default()
})
+114 -54
View File
@@ -9,11 +9,12 @@
//! may contain root-owned device nodes (created via `mknod`), a
//! bind-mounted `/proc` and overlay filesystems, so they require careful
//! unmounting before they can be removed.
//! - **Resumable build sessions** under `/var/tmp/pkh/sessions` (see
//! [`crate::deb::session`]): a kept session is a resumable build; the
//! default policy removes the ones untouched for longer than the session
//! retention window plus the corrupt leftovers, `--all` removes every
//! session.
//! - **Build records** under `/var/tmp/pkh/sessions/<slug>/<record-id>/`
//! (see [`crate::deb::session`]): one immutable record per build; the
//! default policy removes the ones untouched for longer than the
//! record retention window plus the corrupt leftovers, `--all`
//! removes every record (only the last five per target are kept by
//! `pkh deb` itself).
//! - **Cached chroot tarballs** (`~/.cache/pkh/*-buildd.tar.xz`) and their
//! **stale download lockfiles** (`~/.cache/pkh/*.lock` untouched for longer
//! than [`LOCK_STALE_AFTER`]; younger lockfiles may belong to a concurrent
@@ -97,12 +98,12 @@ enum Artifact {
Tarball(PathBuf),
/// A build log file under `<cache>/logs`.
LogFile(PathBuf),
/// A resumable build session root (chroot + journal).
/// A build record root (chroot + journal), one per build.
Session(PathBuf),
/// A `pkh test` run directory (report artifacts).
TestRun(PathBuf),
/// An orphaned session lock file (`<slug>.lock` whose session root is
/// gone).
/// An orphaned record lock file (`<record id>.lock` whose record
/// root is gone).
SessionLock(PathBuf),
}
@@ -127,22 +128,26 @@ impl Artifact {
Artifact::LockFile(_) => "stale lockfile",
Artifact::Tarball(_) => "cached chroot tarball",
Artifact::LogFile(_) => "build log",
Artifact::Session(_) => "resumable build session",
Artifact::Session(_) => "build record",
Artifact::TestRun(_) => "pkh test run",
Artifact::SessionLock(_) => "stale session lockfile",
Artifact::SessionLock(_) => "stale record lockfile",
}
}
}
/// Discover the build sessions that prune may remove under `sessions_dir`:
/// with `--all`, every session; otherwise only those untouched for longer
/// than the retention window ([`crate::deb::session::SESSION_RETENTION`])
/// and the ones whose manifest is missing or unreadable (inert leftovers).
/// Discover the build records prune may remove under `sessions_dir` (the
/// record layout of [`crate::deb::session`]: one `<record-id>` directory
/// — with its `<record-id>.lock` beside it — per slug directory): with
/// `--all`, every record; otherwise only those untouched for longer than
/// the retention window plus the corrupt leftovers.
///
/// Orphaned lock files (`<slug>.lock` whose session root is gone — left
/// behind by failed/interrupted runs) are collected too: by default only
/// the ones no live build holds (probed with a non-blocking flock), with
/// `--all` every one of them.
/// Orphaned lock files (`<record id>.lock` whose record root is gone —
/// left behind by interrupted or discarded runs) are collected too: by
/// default only the ones no live build holds (probed with a non-blocking
/// flock), with `--all` every one of them. The pre-record single-session
/// layout kept its lock at the sessions root and its manifest directly
/// inside the slug directory; both are collected as the leftovers they
/// became.
fn discover_sessions(sessions_dir: &Path, all: bool) -> Vec<Artifact> {
let Ok(entries) = fs::read_dir(sessions_dir) else {
return Vec::new();
@@ -151,21 +156,64 @@ fn discover_sessions(sessions_dir: &Path, all: bool) -> Vec<Artifact> {
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
// The old layout's `<slug>.lock` at the root: orphaned once no
// slug directory of that name exists.
if let Some(slug) = name.strip_suffix(".lock") {
if !path.is_file() || sessions_dir.join(slug).is_dir() {
if path.is_file() && !sessions_dir.join(slug).is_dir() {
collect_lock(&path, all, &mut artifacts);
}
continue;
}
let held = crate::deb::session::lock_is_held(&path).unwrap_or(true);
if all || !held {
artifacts.push(Artifact::SessionLock(path));
let Some(slug_dir) = path.is_dir().then_some(path) else {
continue;
};
let Ok(records) = fs::read_dir(&slug_dir) else {
continue;
};
for record in records.flatten() {
let path = record.path();
let name = record.file_name().to_string_lossy().into_owned();
if let Some(record_id) = name.strip_suffix(".lock") {
if path.is_file() && !slug_dir.join(record_id).is_dir() {
collect_lock(&path, all, &mut artifacts);
}
} else if path.is_dir() && is_prunable_session(&path, all) {
} else if path.is_dir() {
if is_prunable_session(&path, all) {
artifacts.push(Artifact::Session(path));
}
} else if is_stale_path(&path, all) {
// A record root only ever holds directories; a plain
// file inside a slug directory is a leftover of the old
// flat layout (its session.json / host-tree snapshot).
artifacts.push(Artifact::Session(path));
}
}
}
artifacts
}
/// Collect a lock file as prunable when no live build holds it (or `--all`
/// was requested).
fn collect_lock(path: &Path, all: bool, artifacts: &mut Vec<Artifact>) {
let held = crate::deb::session::lock_is_held(path).unwrap_or(true);
if all || !held {
artifacts.push(Artifact::SessionLock(path.to_path_buf()));
}
}
/// Whether a path is untouched for longer than the record retention
/// window (mtime-based; the fallback for leftovers with no manifest).
fn is_stale_path(path: &Path, all: bool) -> bool {
all || fs::metadata(path)
.and_then(|meta| meta.modified())
.is_ok_and(|mtime| {
chrono::Utc::now()
.signed_duration_since(chrono::DateTime::<chrono::Utc>::from(mtime))
.to_std()
.is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION)
})
}
/// Discover the `pkh test` run directories prune may remove under
/// `tests_dir`: with `--all`, every run; otherwise only those untouched
/// for longer than the sessions retention window.
@@ -177,24 +225,17 @@ fn discover_test_runs(tests_dir: &Path, all: bool) -> Vec<Artifact> {
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter(|path| {
all || fs::metadata(path)
.and_then(|meta| meta.modified())
.is_ok_and(|mtime| {
chrono::Utc::now()
.signed_duration_since(chrono::DateTime::<chrono::Utc>::from(mtime))
.to_std()
.is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION)
})
})
.filter(|path| is_stale_path(path, all))
.map(Artifact::TestRun)
.collect()
}
/// Whether a session root is prunable under the given policy: everything
/// Whether a record root is prunable under the given policy: everything
/// with `--all`, otherwise stale (untouched beyond the retention window)
/// or corrupt (no readable manifest) sessions. Live sessions record a
/// fresh `last_used` on every journal write and are never stale.
/// or corrupt (no readable manifest) records. Live records record a fresh
/// `last_used` when they are created and are never stale; a record whose
/// manifest is missing is mid-creation rather than corrupt while its
/// lock is held, and is left alone.
fn is_prunable_session(path: &Path, all: bool) -> bool {
if all {
return true;
@@ -213,8 +254,18 @@ fn is_prunable_session(path: &Path, all: bool) -> bool {
.signed_duration_since(then)
.to_std()
.is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION),
// No readable manifest: an inert leftover.
None => true,
// No readable manifest: a leftover — unless a live build holds
// the record's lock (mid-creation or mid-fork); an unreadable
// probe also errs on keeping it.
None => {
let lock = path.with_file_name(format!(
"{}.lock",
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
));
matches!(crate::deb::session::lock_is_held(&lock), Ok(false))
}
}
}
@@ -1221,7 +1272,8 @@ none /tmp/other proc rw 0 0
assert!(report.is_empty());
}
/// Write a minimal session manifest with the given `last_used`.
/// Write a minimal record manifest with the given `last_used` under
/// `sessions/<slug>/<record>/`.
fn write_session_manifest(session_dir: &Path, last_used: &str) {
fs::create_dir_all(session_dir).unwrap();
fs::write(
@@ -1231,15 +1283,16 @@ none /tmp/other proc rw 0 0
.unwrap();
}
/// Session pruning: stale and corrupt sessions go by default, live ones
/// Record pruning: stale and corrupt records go by default, live ones
/// stay; `--all` takes everything.
#[test]
fn test_session_pruning_retention() {
let temp = tempdir().unwrap();
let sessions = temp.path().join("sessions");
let stale = sessions.join("stale");
let fresh = sessions.join("fresh");
let corrupt = sessions.join("corrupt");
let slug = sessions.join("pkh-noble-amd64-1c0ffee0");
let stale = slug.join("20200101T000000");
let fresh = slug.join("20260929T000000");
let corrupt = slug.join("20260929T000001");
write_session_manifest(&stale, "2020-01-01T00:00:00+00:00");
write_session_manifest(
&fresh,
@@ -1258,11 +1311,11 @@ none /tmp/other proc rw 0 0
.unwrap();
assert!(report.removed.contains(&stale));
assert!(report.removed.contains(&corrupt));
assert!(fresh.exists(), "a live session is not pruned by default");
assert!(fresh.exists(), "a live record is not pruned by default");
assert!(!stale.exists());
assert!(!corrupt.exists());
// --all removes everything, including the live session.
// --all removes everything, including the live record.
let report = prune_in_roots(
temp.path(),
None,
@@ -1325,22 +1378,27 @@ none /tmp/other proc rw 0 0
assert!(!fresh.exists());
}
/// Orphaned session lock files are pruned (unless held by a live
/// build); locks whose session root still exists are never touched.
/// Orphaned record lock files are pruned (unless held by a live
/// build); locks whose record root still exists are never touched.
/// The pre-record layout's locks at the sessions root follow the same
/// rules.
#[test]
fn test_session_lock_pruning() {
use std::os::unix::io::AsRawFd;
let temp = tempdir().unwrap();
let sessions = temp.path().join("sessions");
let orphan_free = sessions.join("free.lock");
let orphan_held = sessions.join("held.lock");
let rooted = sessions.join("rooted.lock");
fs::create_dir_all(&sessions).unwrap();
let slug = sessions.join("pkh-noble-amd64-1c0ffee0");
let orphan_free = slug.join("20260929T000000.lock");
let orphan_held = slug.join("20260929T000001.lock");
let rooted = slug.join("20260929T000002.lock");
let legacy = sessions.join("legacy-slug.lock");
fs::create_dir_all(&slug).unwrap();
fs::write(&orphan_free, b"").unwrap();
fs::write(&orphan_held, b"").unwrap();
fs::write(&rooted, b"").unwrap();
fs::create_dir_all(sessions.join("rooted")).unwrap();
fs::create_dir_all(slug.join("20260929T000002")).unwrap();
fs::write(&legacy, b"").unwrap();
// Hold one orphaned lock, as a live build would.
let holder = fs::OpenOptions::new()
@@ -1361,10 +1419,12 @@ none /tmp/other proc rw 0 0
.unwrap();
assert!(report.removed.contains(&orphan_free));
assert!(!orphan_free.exists());
assert!(report.removed.contains(&legacy));
assert!(!legacy.exists());
assert!(orphan_held.exists(), "a held lock belongs to a live build");
assert!(rooted.exists(), "a lock guarding an existing root stays");
assert!(rooted.exists(), "a lock guarding an existing record stays");
// --all takes the held orphan too (the root it guarded is gone).
// --all takes the held orphan too (the record it guarded is gone).
drop(holder);
let report = prune_in_roots(
temp.path(),