deb: hand the staged tree to the build user selectively and in parallel

The handover chowned the whole build root with a serial 'chown -R'.
Through the overlay staging every lower-layer file it touched is
copy-up'd — the full data movement of the staged tree — which the
profile of a kernel build showed as 15m53s in 'Preparing the build
user', 19% of the whole run.

The handover is now a 'find ! -uid | xargs -P chown' pipeline: only
entries the build user does not own yet are chowned (staging and quilt
re-application flip entries back to root; a resumed tree is already
owned and the phase collapses to a metadata-only walk), and the
copy-ups of independent files parallelize across the chroot's cores.
On a 200k-file tree the handover drops from 9.9s to 0.9s.

The ccache handover ran only when the build user was fresh, which is
every build in practice (the chroot is per-run scratch); it now rides
the same selective pipeline on every run, still best-effort — a failed
handover costs cache hits, never the build.
This commit is contained in:
2026-10-03 04:34:20 +02:00
parent ae679f29d9
commit 5544873107
+66 -22
View File
@@ -116,8 +116,7 @@ fn ensure_build_user(
.output()
};
let probe = id_of(ctx, "-u").map_err(|e| format!("cannot look up {BUILD_USER}: {e}"))?;
let fresh = !probe.status.success();
if fresh {
if !probe.status.success() {
create_build_user(ctx)?;
}
@@ -135,30 +134,53 @@ fn ensure_build_user(
}
}
// The shared ccache directory moves once, when the user is fresh: it
// was root-owned by earlier root builds, and root keeps writing
// through the new ownership anyway.
if fresh && let Some(dir) = ccache_dir {
let _ = ctx
.command("chown")
.arg("-R")
.arg(format!("{}:{}", ids.0, ids.1))
.arg(dir)
.status();
for dir in [Some(workroot), Some(Path::new("/tmp/pkh-home"))]
.into_iter()
.flatten()
{
hand_over(ctx, dir, &ids)?;
}
for dir in [workroot, Path::new("/tmp/pkh-home")] {
// The shared cache degrades to per-run compiles when it cannot be
// handed over: slower, never wrong.
if let Some(dir) = ccache_dir {
let _ = hand_over(ctx, Path::new(dir), &ids);
}
Ok(ids)
}
/// The shell pipeline handing `dir` to the build identity: chown only the
/// entries it does not own yet, run in parallel.
///
/// Through the overlay staging a chown copy-ups every lower-layer file it
/// touches — the full data movement of the staged tree. The serial
/// `chown -R` of a kernel tree spends minutes on it; this pipeline pays
/// the copy-up only for entries a root-side phase flipped back to root
/// (staging, quilt re-application), and the copy-ups of independent files
/// parallelize across the chroot's cores. On a resumed tree every entry
/// is already owned and the phase collapses to a metadata-only walk.
fn hand_over_script(dir: &str, uid: &str, gid: &str) -> String {
format!(
"n=$(nproc 2>/dev/null || echo 4); \
find {} \\! -uid {uid} -print0 \
| xargs -0 -r -P \"$n\" -n 512 chown -- {uid}:{gid}",
shlex_quote(dir)
)
}
/// Run [`hand_over_script`] against `dir`, failing the build when the
/// escalation cannot complete: a tree half-owned by root fails the rules
/// on their first write into it.
fn hand_over(ctx: &Arc<Context>, dir: &Path, ids: &(String, String)) -> Result<(), Box<dyn Error>> {
let status = ctx
.command("chown")
.arg("-R")
.arg(format!("{}:{}", ids.0, ids.1))
.arg(dir)
.command("sh")
.arg("-c")
.arg(hand_over_script(&dir.to_string_lossy(), &ids.0, &ids.1))
.status()
.map_err(|e| format!("cannot hand {} to {BUILD_USER}: {e}", dir.display()))?;
if !status.success() {
return Err(format!("cannot hand '{}' to '{BUILD_USER}'", dir.display()).into());
}
}
Ok(ids)
Ok(())
}
/// Create [`BUILD_USER`] in the build context. Minimal mmdebstrap chroots
@@ -691,9 +713,10 @@ pub async fn build(
// LTO/make -j process flood), and a rules chown that fails for the
// build user is cosmetic — the .deb is assembled in the binary step's
// own fakeroot session, where files unknown to its database are
// recorded as root-owned anyway. Handing the tree over is a recursive
// chown that can take minutes on a large carried tree, so it is a
// phase of its own in the status bar and the profile.
// recorded as root-owned anyway. Handing the tree over is a selective,
// parallel chown (through the overlay staging each chown copy-ups the
// file it touches), and it is a phase of its own in the status bar and
// the profile.
if build_user.is_some() {
enter_phase(view, Phase::PreparingBuildUser, profiler);
}
@@ -1662,6 +1685,27 @@ mod tests {
crate::debian::deps::parse_simple(s, true).unwrap()
}
/// The handover pipeline chowns only entries the build user does not
/// own (a resumed tree needs no re-chown of already-owned files), is
/// parallel across the chroot's cores, and survives any spelling of
/// the staged path.
#[test]
fn hand_over_script_selects_and_parallelizes() {
let script = hand_over_script("/tmp/pkh-build/tree", "64500", "64500");
assert!(
script.contains("find '/tmp/pkh-build/tree' \\! -uid 64500 -print0"),
"{script}"
);
assert!(script.contains("xargs -0 -r -P"), "{script}");
assert!(script.contains("chown -- 64500:64500"), "{script}");
let quoted = hand_over_script("/tmp/some'dir", "1000", "1000");
assert!(
quoted.contains("find '/tmp/some'\\''dir'"),
"embedded quotes must be shell-quoted: {quoted}"
);
}
/// The build env carries the ccache wiring when it is enabled: the
/// compiler shim first on PATH, the shared cache dir, content-keyed
/// compiler checks and the size cap.