Files
pkh/src/main.rs
T
vhaudiquet 2ab2ff91e9 deb: record per-phase build timings, shown by pkh deb profile
Time every Phase the build flow announces through enter_phase — the
chroot bootstrap (tarball download, extraction, finalization), the
apt phases, patch application, the build and binary steps and the
artifact retrieval — and land the ordered profile in the record's
manifest of every run, failed builds included: their profile ends
at the phase the error propagated out of.

Timing rides the enter_phase choke point instead of new measurement
sites, so a phase added later profiles itself. The manifest field
is additive (serde default, skipped when absent), so older manifests
keep loading without a schema bump. The coarse timings stay the
source of the deb list columns: they measure the apt transactions
themselves, where the profile measures whole phases.

pkh deb profile renders a record's profile through the same
read-only record discovery as pkh deb log — the newest record of
the tree, or an id prefix as shown by pkh deb list — so
record_for_log becomes the neutral find_record. Phase keys are
storage names, distinct from the display labels; Phase::from_key
turns them back and a test keeps both in sync.
2026-10-02 14:17:15 +02:00

1386 lines
68 KiB
Rust

use std::env;
use std::io::Write;
extern crate clap;
use clap::{Command, arg, command};
extern crate flate2;
use indicatif_log_bridge::LogWrapper;
use log::{error, info};
/// Obtain the current working directory, exiting with a helpful message on failure.
fn current_dir_or_exit() -> std::path::PathBuf {
match std::env::current_dir() {
Ok(p) => p,
Err(e) => {
error!("Could not determine the current working directory: {}", e);
std::process::exit(1);
}
}
}
/// CLI-side Ctrl+C wiring. The passive state (interrupted flag, cleanup
/// hook registry, reporter slot) lives in `pkh::interrupt`; everything that
/// installs, prints or exits lives here: the SIGINT handler only wakes a
/// watchdog through a self-pipe (async-signal-safe), and the watchdog runs
/// the whole shutdown in thread context — the live view's reporter clears
/// the terminal, the notice is printed, further Ctrl+C is absorbed as a
/// no-op, the cleanup hooks release their resources, and the process exits
/// with the conventional status 130, skipping destructors.
mod interrupt {
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
/// Whether the interrupt notice has been shown; the first caller prints
/// it, later ones stay silent
static NOTICE_SHOWN: AtomicBool = AtomicBool::new(false);
/// Whether handler, self-pipe and watchdog are in place
static INSTALLED: AtomicBool = AtomicBool::new(false);
/// Write end of the self-pipe the signal handler wakes the watchdog
/// through; `-1` until [`install`] set it up
static SELF_PIPE_WRITE: AtomicI32 = AtomicI32::new(-1);
/// Install the process-global Ctrl+C (SIGINT) handler; idempotent.
///
/// When the self-pipe or the watchdog cannot be set up, the default
/// SIGINT disposition is kept (the process dies immediately) rather
/// than installing a handler that could not run the shutdown.
pub fn install() {
if INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
let mut fds = [0 as libc::c_int; 2];
// SAFETY: pipe(2) into a two-element array we own
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
INSTALLED.store(false, Ordering::SeqCst);
return;
}
let (read_fd, write_fd) = (fds[0], fds[1]);
// The write end is used from the signal handler: non-blocking, so
// even a full pipe degrades to a dropped wake-up instead of
// blocking the handler.
// SAFETY: fcntl(2) on a pipe file descriptor we just created
unsafe {
let flags = libc::fcntl(write_fd, libc::F_GETFL);
libc::fcntl(write_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
SELF_PIPE_WRITE.store(write_fd, Ordering::SeqCst);
let spawned = std::thread::Builder::new()
.name("pkh-interrupt".to_string())
.spawn(move || watchdog(read_fd));
if spawned.is_err() {
// SAFETY: closing pipe file descriptors we just created
unsafe {
libc::close(read_fd);
libc::close(write_fd);
}
SELF_PIPE_WRITE.store(-1, Ordering::SeqCst);
INSTALLED.store(false, Ordering::SeqCst);
return;
}
// SAFETY: installing a signal handler whose body only records the
// interruption and writes to the self-pipe (async-signal-safe)
unsafe {
libc::signal(libc::SIGINT, on_sigint as *const () as usize);
}
}
/// Never returns: park the calling thread until the watchdog exits the
/// process
///
/// The watchdog owns the interrupt shutdown; a caller that would
/// otherwise reach its own `std::process::exit` and kill the process
/// mid-cleanup must park here instead.
pub fn wait_for_shutdown() -> ! {
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
/// Signal handler body: record the interruption and wake the watchdog
/// through the self-pipe
extern "C" fn on_sigint(_sig: libc::c_int) {
pkh::interrupt::mark_interrupted();
let fd = SELF_PIPE_WRITE.load(Ordering::SeqCst);
if fd >= 0 {
// SAFETY: write(2) of one byte to the self-pipe is
// async-signal-safe; a failed write (e.g. EAGAIN) drops the
// wake-up instead of blocking the handler
unsafe {
libc::write(fd, b"x".as_ptr().cast(), 1);
}
}
}
/// Watchdog body: block until the signal handler's byte arrives, then
/// run the shutdown sequence
fn watchdog(read_fd: libc::c_int) {
let mut byte = [0u8; 1];
// SAFETY: read(2) into a local buffer of the announced length
let received = unsafe { libc::read(read_fd, byte.as_mut_ptr().cast(), 1) };
// The write end is never closed, so a short read cannot happen in
// practice; on error there is nothing to clean up either way.
if received > 0 {
run_interrupt_sequence();
}
}
/// Reporter, notice, cleanup hooks, exit 130: the whole shutdown, run
/// in the watchdog thread immediately on Ctrl+C — never in the signal
/// handler itself
///
/// Further Ctrl+C while this runs writes bytes nobody reads: absorbed
/// as a no-op (send SIGTERM/SIGKILL if a hook ever hangs).
fn run_interrupt_sequence() {
let hint =
pkh::interrupt::take_reporter().and_then(|report| {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(report)) {
Ok(hint) => hint,
Err(_) => {
log::error!("Interrupt reporter panicked");
None
}
}
});
show_notice();
if let Some(hint) = hint {
eprintln!("{hint}");
}
pkh::interrupt::run_cleanup_hooks();
// SAFETY: raw exit bypassing destructors, intended at interrupt time
unsafe {
libc::_exit(130);
}
}
/// Print the interrupt notice, once per process: the first caller
/// prints it, later ones stay silent
fn show_notice() {
if NOTICE_SHOWN.swap(true, Ordering::SeqCst) {
return;
}
eprintln!("CTRL+C: Build interrupted by user.");
}
}
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
let logger =
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp(None)
.format(|buf, record| writeln!(buf, "{}", record.args()))
.build();
let multi = indicatif::MultiProgress::new();
LogWrapper::new(multi.clone(), logger).try_init().unwrap();
let matches = command!()
.subcommand_required(true)
.subcommand(
Command::new("new")
.about("Scaffold a new Debian source package (buildable right away)")
.arg(arg!([name] "Package name: creates ./<name>/ with a fresh project skeleton. Without it (or with --source), the given/current directory is packaged"))
// NOTE: hyphenated long names are defined via the builder API
// because clap's `arg!` macro mis-tokenizes them (see the
// prune subcommand note below).
.arg(
clap::Arg::new("lang")
.long("lang")
.value_name("LANG")
.help("Language/build system: rust, python, meson, cmake, autotools, go, shell, makefile or empty"),
)
.arg(
clap::Arg::new("source")
.long("source")
.value_name("PATH")
.conflicts_with("name")
.help("Package the sources in PATH instead of creating ./<name>/"),
)
.arg(
clap::Arg::new("upstream_version")
.long("upstream-version")
.value_name("VERSION")
.help("Upstream version (default: 0.1.0)"),
)
.arg(
clap::Arg::new("revision")
.long("revision")
.value_name("N")
.value_parser(clap::value_parser!(u32))
.help("Debian revision (default: 1)"),
)
.arg(
clap::Arg::new("description")
.long("description")
.value_name("DESC")
.help("One-line package description"),
)
.arg(
clap::Arg::new("homepage")
.long("homepage")
.value_name("URL")
.help("Upstream homepage (http:// or https://)"),
)
.arg(
clap::Arg::new("license")
.long("license")
.value_name("SPDX")
.help("Upstream license (SPDX identifier, e.g. MIT, GPL-3.0+)"),
)
.arg(
clap::Arg::new("command")
.long("command")
.value_name("CMD")
.help("Installed command name (default: the package name)"),
)
.arg(
clap::Arg::new("maintainer")
.long("maintainer")
.value_name("NAME <EMAIL>")
.help("Maintainer (default: DEBFULLNAME/DEBEMAIL, then git config)"),
)
.arg(
clap::Arg::new("depends")
.long("depends")
.value_name("LIST")
.action(clap::ArgAction::Append)
.long_help("Runtime Depends of the metapackage flavor ('empty' template), as a comma-separated list (e.g. \"hello, hello-data (>= 1.0)\"). Can be specified multiple times.")
.help("Metapackage Depends list, comma-separated ('empty' template only)"),
)
.arg(
clap::Arg::new("dist")
.long("dist")
.value_name("DIST")
.help("Target distribution: debian or ubuntu (default: current vendor)"),
)
.arg(
clap::Arg::new("series")
.long("series")
.value_name("SERIES")
.help("Target series (default: the development series of --dist)"),
)
.arg(arg!(--release "Write the --series into debian/changelog instead of UNRELEASED").required(false))
.arg(arg!(--native "Use the 3.0 (native) source format (default for a new project skeleton: no orig tarball)").required(false))
.arg(
clap::Arg::new("quilt")
.long("quilt")
.action(clap::ArgAction::SetTrue)
.conflicts_with("native")
.help("Use the 3.0 (quilt) source format with an orig tarball (default when packaging an existing project)"),
)
.arg(
clap::Arg::new("orig_from")
.long("orig-from")
.value_name("MODE")
.value_parser(["release", "git", "path", "snapshot"])
.conflicts_with("native")
.help("How to produce the orig tarball (quilt only): release (download the forge tarball of the tag; network), git (git archive of the tag), path (repack --orig-path), snapshot (tar the working tree). Default: git on a tag, snapshot otherwise"),
)
.arg(
clap::Arg::new("orig_path")
.long("orig-path")
.value_name("FILE|URL")
.conflicts_with("native")
.help("Tarball used by --orig-from path: a local file or an http(s) URL (.tar, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar.xz), repacked to the orig"),
)
.arg(
clap::Arg::new("no_git")
.long("no-git")
.action(clap::ArgAction::SetTrue)
.help("Do not initialize a git repository (.gitignore files are written anyway)"),
)
.arg(
clap::Arg::new("no_verify")
.long("no-verify")
.action(clap::ArgAction::SetTrue)
.help("Skip the post-scaffold build verification (the structural self-checks always run)"),
)
.arg(arg!(--defaults "Take the default answer for every question left unanswered (the package name is still required)").required(false)),
)
.subcommand(
Command::new("pull")
.about("Pull a source package from the archive or git")
.arg(
arg!(-s --series <series> "Target package distribution series").required(false),
)
.arg(
arg!(-d --dist <dist> "Target package distribution (debian, ubuntu)")
.required(false),
)
.arg(arg!(-v --version <version> "Target package version").required(false))
.arg(arg!(--archive "Only use the archive to download package source, not git").required(false))
.arg(arg!(--ppa <ppa> "Download the package from a specific PPA (format: user/ppa_name)").required(false))
.arg(arg!(--repository <url> "Download the package from an external flat repository, given as its full suite URL (e.g. https://pkg.noctalia.dev/deb/resolute/)").required(false)
.conflicts_with("ppa"))
.arg(arg!(-p --pocket <pocket> "Target package distribution pocket (updates, security, proposed)").required(false))
.arg(arg!(<package> "Target package")),
)
.subcommand(
Command::new("chlog")
.about("Auto-generate changelog entry, editing it, committing it afterwards")
.arg(arg!(-s --series <series> "Target distribution series").required(false))
.arg(arg!(--backport "Number the entry as a backport of the target series (Debian: 1.0-1 becomes 1.0-1~bpo12+1; Ubuntu: 3.1-1ubuntu2 becomes 3.1-1ubuntu2~24.04.1)").required(false)
.conflicts_with_all(["nmu", "rebuild"]))
.arg(arg!(--nmu "Number the entry as a non-maintainer upload (1.0-1 becomes 1.0-1.1, native 1.0 becomes 1.0+nmu1)").required(false)
.conflicts_with("rebuild"))
.arg(arg!(--rebuild "Number the entry as a no-change rebuild (1.0-1 becomes 1.0-1build1)").required(false))
.arg(arg!(-v --version <version> "Target version (overrides the --backport/--nmu/--rebuild numbering)").required(false))
.arg(arg!(-f --file <path> "Path to the changelog file to generate into and edit (default: debian/changelog)").required(false).default_value("debian/changelog"))
.arg(arg!(--limit <n> "Cap how far back the history is searched for the previous changelog entry when no version tag exists (0 = unlimited)").required(false)
.value_parser(clap::value_parser!(usize)).default_value("500")),
)
.subcommand(
Command::new("build")
.about("Build the source package (into a .dsc)")
.arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false))
.arg(arg!(--orig <when> "Original source tarball in the upload [auto, always, never] (default: auto)").required(false)
.long_help("Whether the upload distributes the original source tarball(s), like dpkg-genchanges' -sa/-si/-sd source styles.\nauto: include them only when the upstream version changed since the previous changelog entry (the default);\nalways: force inclusion, even for a revision bump of the same upstream version;\nnever: never include them, even for a new upstream version.\nAn explicit value is ignored with a warning for native packages (they have no separate orig tarball).")
.value_parser(["auto", "always", "never"])),
)
.subcommand(
Command::new("put")
.about("Upload the built source package to a PPA")
.arg(arg!(--ppa <ppa> "Upload to a PPA (format: user/ppa_name)"))
.arg(arg!([changes] "Explicit .changes file to upload (default: the one built from this package, next to the source tree)").required(false))
.arg(arg!(--force "Upload even if this exact .changes file was already uploaded to the target")),
)
.subcommand(
Command::new("deb")
.about("Build the source package into binary package (.deb)")
.arg(arg!(-s --series <series> "Target distribution series").required(false))
.arg(arg!(-a --arch <arch> "Target architecture").required(false))
.arg(arg!(-p --pocket <pocket> "Build against dependencies from a specific distribution pocket (updates, security, proposed)").required(false)
.long_help("Build against dependencies from a specific distribution pocket (e.g. updates, security, proposed).\nThe '<series>-<pocket>' suite will be enabled on archive sources when resolving build-dependencies."))
.arg(arg!(--mirror <url> "Resolve every package of the build from a specific mirror (base URL)").required(false)
.long_help("Build the chroot and resolve every package from the given base URL — a caching proxy or a chosen mirror on a pinned runner — instead of the distro default: the mirror is passed to mmdebstrap at bootstrap, so the chroot tarball's apt sources carry it and every apt inside the build follows.\nThe mirror must serve every suite and architecture the build resolves: the security pocket and, for cross builds, the target architecture's archive included. PPAs and other third-party sources are left alone. The tarball cache is keyed per mirror, so pinned and unpinned builds keep separate tarballs."))
.arg(arg!(--ppa <ppa> "Build the package adding a specific PPA for dependencies (can be specified multiple times)")
.long_help("Build the package adding a specific PPA for dependencies. Can be specified multiple times.").required(false).action(clap::ArgAction::Append))
.arg(arg!(--inject <package> "Inject a package into the build environment (can be specified multiple times)")
.long_help("Inject a package into the build environment before build-dep. Can be a .deb file path, a package name from the archive, or a package from a previously added PPA. Can be specified multiple times.").required(false).action(clap::ArgAction::Append))
.arg(arg!(--cross "Cross-compile for target architecture (instead of qemu-binfmt)")
.long_help("Cross-compile for target architecture (instead of using qemu-binfmt)\nNote that most packages cannot be cross-compiled").required(false))
.arg(arg!(--mode <mode> "Change build mode [local]").required(false)
.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("incremental")
.long("incremental")
.value_name("ID")
.num_args(0..=1)
.require_equals(false)
.default_missing_value("")
.help("Incremental build: fork a previous build record of this target and keep its staged tree")
.long_help("Incremental build: fork a previous build record of this target — at an unchanged changelog version and while the chroot tarball is unchanged, the base's staged tree is copied into a new record, so make recompiles only what changed.\nWithout a value, the newest successful record of the target (among the last five carrying a staged tree) is the base; with an ID (as shown by 'pkh deb list'), that exact record — a failed one is fine if its staged tree is.\nEvery --incremental run keeps its staged tree, success or failure, so the next one can fork it; a plain 'pkh deb' keeps only metadata and its build log (the chroot is per-run and always discarded)."),
)
.arg(arg!(--"no-ccache" "Build without the shared ccache (escape hatch for kbuild-style rules that mishandle compiler shims)")
.long_help("Build without the shared ccache (its compiler PATH shim is skipped and no cache is mounted). Escape hatch for packages whose debian/rules mishandles a compiler shim, e.g. some kernel flavour configs."))
.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 build records recorded from this tree")
.long_about("List the build records recorded from this tree (plain runs too, as metadata records), with their outcome, version, fork lineage, whether their staged tree is kept, how long the dependency installation and the package build took, and the id to pass to 'pkh deb --incremental <id>'.\nRead-only: never builds or removes anything (at most five staged trees per target are kept, 'pkh prune' removes records older than a week)."),
)
.subcommand(
Command::new("log")
.about("Print a recorded build's log")
.long_about("Print the build log of a recorded build: the full tee of its output.\nWithout an ID, the newest record of this tree; with an ID (as shown by 'pkh deb list'), that exact record.\n--follow keeps reading a live build's log as it is written and exits once the build's record reaches a terminal state.")
.arg(arg!([id] "Record id (default: the newest record of this tree)").required(false))
.arg(arg!(-f --follow "Keep reading the log as the build writes it")),
)
.subcommand(
Command::new("profile")
.about("Print a recorded build's per-phase timing profile")
.long_about("Print the timing profile of a recorded build: how long every phase took, from the chroot bootstrap (tarball download, extraction, finalization) through the apt phases (package lists, essential packages, injected packages, build dependencies) and patch application to the build and binary steps and the artifact retrieval, each with its share of the total.\nWithout an ID, the newest record of this tree; with an ID (as shown by 'pkh deb list'), that exact record. A failed build's profile ends at the phase the error surfaced from.")
.arg(arg!([id] "Record id (default: the newest record of this tree)").required(false)),
)
)
.subcommand(
Command::new("test")
.about("Run the package's DEP-8 as-installed tests (autopkgtests)")
.arg(arg!([path] "Source tree to test (default: the current directory)").required(false))
.arg(arg!(-s --series <series> "Target distribution series").required(false))
.arg(arg!(-d --dist <dist> "Target distribution (debian, ubuntu)").required(false))
.arg(arg!(-a --arch <arch> "Target architecture").required(false))
.arg(arg!(-p --pocket <pocket> "Resolve test dependencies from a specific distribution pocket (updates, security, proposed)").required(false))
.arg(arg!(--ppa <ppa> "Add a PPA as test dependency source (can be specified multiple times)").required(false).action(clap::ArgAction::Append))
.arg(arg!(--inject <package> "Inject a package into the testbed before the tests (.deb path or package name, can be specified multiple times)").required(false).action(clap::ArgAction::Append))
.arg(arg!(--mode <mode> "Testbed mode: chroot (default, an ephemeral unshare chroot), local (the current context, no isolation) or vm (a qemu VM through autopkgtest)").required(false))
.arg(arg!(--image <file> "VM image for --mode vm (required there)").required(false))
.arg(arg!(--cpus <n> "VM virtual CPUs (vm mode)").required(false).value_parser(clap::value_parser!(u32)))
.arg(arg!(--"ram-size" <mib> "VM memory size in MiB (vm mode)").required(false).value_parser(clap::value_parser!(u32)))
.arg(arg!(--debs <deb> "Explicit binary package to test, overriding the pkh deb output reuse (can be specified multiple times)").required(false).action(clap::ArgAction::Append))
.arg(
clap::Arg::new("no_build")
.long("no-build")
.action(clap::ArgAction::SetTrue)
.help("Never build: fail when no fresh 'pkh deb' output exists next to the tree"),
)
.arg(arg!(--build "Ignore fresh pkh deb output and rebuild the binaries first").required(false))
.arg(
clap::Arg::new("test_name")
.long("test-name")
.value_name("NAME")
.action(clap::ArgAction::Append)
.help("Run only this test (can be specified multiple times)"),
)
.arg(
clap::Arg::new("skip_test")
.long("skip-test")
.value_name("NAME")
.action(clap::ArgAction::Append)
.help("Skip this test, reporting it as skipped (can be specified multiple times)"),
)
.arg(
clap::Arg::new("setup_commands")
.long("setup-commands")
.value_name("CMD")
.action(clap::ArgAction::Append)
.help("Shell command run in the testbed before the tests (can be specified multiple times)"),
)
.arg(
clap::Arg::new("apt_upgrade")
.long("apt-upgrade")
.action(clap::ArgAction::SetTrue)
.help("Update, upgrade and autopurge the testbed before the tests"),
)
.arg(arg!(--shell "Drop into an interactive testbed shell after the run").required(false))
.arg(
clap::Arg::new("shell_fail")
.long("shell-fail")
.action(clap::ArgAction::SetTrue)
.help("Drop into an interactive testbed shell when a test failed"),
)
.arg(arg!(--timeout <secs> "Per-test timeout in seconds (default: 10000)").required(false).value_parser(clap::value_parser!(u64)))
.arg(
clap::Arg::new("timeout_factor")
.long("timeout-factor")
.value_name("FACTOR")
.value_parser(clap::value_parser!(f64))
.help("Scale the default timeouts (an explicit --timeout stands as given)"),
)
.arg(
clap::Arg::new("fail_on")
.long("fail-on")
.value_name("TRIGGERS")
.help("Comma-separated extra failure triggers: skip, no-tests (default: none)"),
)
.arg(
clap::Arg::new("list_tests")
.long("list-tests")
.action(clap::ArgAction::SetTrue)
.help("Print the tests declared in debian/tests/control and exit"),
)
.arg(arg!(--json "Emit the report as JSON").required(false))
.arg(arg!(--verbose "Show raw test output instead of the summarized live view").required(false)),
)
.subcommand(
Command::new("lint")
.about("Lint the package (lintian wrapper + pkh-native checks)")
.arg(arg!([path] "Source tree to lint (default: the current directory)").required(false))
.arg(arg!(-d --dist <dist> "Target distribution (debian, ubuntu)").required(false))
.arg(arg!(-s --series <series> "Target distribution series").required(false))
.arg(arg!(--native "Run pkh-native checks only, without the lintian wrapper").required(false))
.arg(arg!(--info "Show tag explanations under each finding").required(false))
.arg(
clap::Arg::new("display_info")
.long("display-info")
.action(clap::ArgAction::SetTrue)
.help("Also display info-level tags (I:)"),
)
.arg(arg!(--pedantic "Also display pedantic tags (P:)").required(false))
.arg(arg!(--experimental "Also display experimental tags (X:)").required(false))
.arg(
clap::Arg::new("show_overrides")
.long("show-overrides")
.action(clap::ArgAction::SetTrue)
.help("Also display overridden tags (O:)"),
)
.arg(
clap::Arg::new("fail_on")
.long("fail-on")
.value_name("LEVELS")
.help("Comma-separated severities failing the run: error, warning, info, pedantic, experimental, override (default: error)"),
)
.arg(
clap::Arg::new("suppress_tags")
.long("suppress-tags")
.value_name("LIST")
.help("Comma-separated tag names to ignore for this run"),
)
.arg(
clap::Arg::new("check")
.long("check")
.value_name("NAME")
.action(clap::ArgAction::Append)
.help("Run only this pkh-native check (can be specified multiple times)"),
)
.arg(arg!(--repack "Ignore existing pkh build output and pack the tree fresh for linting").required(false))
.arg(arg!(--json "Emit the report as JSON").required(false))
.arg(
clap::Arg::new("color")
.long("color")
.value_name("WHEN")
.value_parser(["auto", "always", "never"])
.help("Colorize the report: auto, always or never (default: auto)"),
)
.arg(
clap::Arg::new("list_tags")
.long("list-tags")
.action(clap::ArgAction::SetTrue)
.help("Print the pkh-native tag catalog and exit"),
),
)
.subcommand(
Command::new("prune")
.about("Prune residual pkh build artifacts and caches")
// NOTE: --dry-run is defined via the builder API because clap's
// `arg!` macro mis-tokenizes hyphenated long names (it would
// parse `--dry-run` as long="dry" plus a spurious short flag,
// tripping the "Short flags should precede long flags" assert).
.arg(
clap::Arg::new("dry_run")
.long("dry-run")
.action(clap::ArgAction::SetTrue)
.help("List what would be removed without removing anything"),
)
.arg(
clap::Arg::new("all")
.long("all")
.action(clap::ArgAction::SetTrue)
.help("Also remove cached chroot tarballs (expensive to re-download)"),
)
)
.get_matches();
match matches.subcommand() {
Some(("new", sub_matches)) => {
let depends: Vec<String> = sub_matches
.get_many::<String>("depends")
.map(|values| values.cloned().collect())
.unwrap_or_default();
let no_verify = sub_matches
.get_one::<bool>("no_verify")
.copied()
.unwrap_or(false);
let cli = pkh::new::options::NewCli {
name: sub_matches.get_one::<String>("name").cloned(),
lang: sub_matches.get_one::<String>("lang").cloned(),
source: sub_matches
.get_one::<String>("source")
.map(std::path::PathBuf::from),
upstream_version: sub_matches.get_one::<String>("upstream_version").cloned(),
revision: sub_matches.get_one::<u32>("revision").copied(),
description: sub_matches.get_one::<String>("description").cloned(),
homepage: sub_matches.get_one::<String>("homepage").cloned(),
license: sub_matches.get_one::<String>("license").cloned(),
command: sub_matches.get_one::<String>("command").cloned(),
maintainer: sub_matches.get_one::<String>("maintainer").cloned(),
depends,
dist: sub_matches.get_one::<String>("dist").cloned(),
series: sub_matches.get_one::<String>("series").cloned(),
release: sub_matches
.get_one::<bool>("release")
.copied()
.unwrap_or(false),
native: sub_matches
.get_one::<bool>("native")
.copied()
.unwrap_or(false),
quilt: sub_matches
.get_one::<bool>("quilt")
.copied()
.unwrap_or(false),
orig_from: sub_matches.get_one::<String>("orig_from").cloned(),
orig_path: sub_matches.get_one::<String>("orig_path").cloned(),
git: !sub_matches
.get_one::<bool>("no_git")
.copied()
.unwrap_or(false),
defaults: sub_matches
.get_one::<bool>("defaults")
.copied()
.unwrap_or(false),
};
// The wizard (interactive terminal) fills the same NewCli and
// resolves through the same pipeline; without a TTY the resolve
// error lists every missing answer. Afterwards the two
// verification builds are offered (`--no-verify` skips them;
// the structural self-checks inside `scaffold` always run), with
// the scaffold outcome (e.g. a failed vendoring) shaping the
// offer.
let prompter = pkh::ui::prompt::TerminalPrompter;
if let Err(e) = rt.block_on(async {
let opts = pkh::new::questions::run(cli, &prompter).await?;
let outcome = pkh::new::scaffold(opts.clone(), &multi)?;
pkh::new::questions::offer_verification(
&opts, &outcome, &multi, no_verify, &prompter,
)
.await;
Ok::<(), Box<dyn std::error::Error>>(())
}) {
error!("{}", e);
std::process::exit(1);
}
}
Some(("pull", sub_matches)) => {
let package = sub_matches.get_one::<String>("package").expect("required");
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
let dist = sub_matches.get_one::<String>("dist").map(|s| s.as_str());
let version = sub_matches.get_one::<String>("version").map(|s| s.as_str());
let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str());
let repository = sub_matches
.get_one::<String>("repository")
.map(|s| s.as_str());
let pocket = sub_matches
.get_one::<String>("pocket")
.map(|s| s.as_str())
.unwrap_or("");
let archive = sub_matches.get_one::<bool>("archive").unwrap_or(&false);
let (pb, progress_callback) = pkh::ui::create_progress_bar(&multi);
// Convert PPA to base URL if provided
let base_url = match ppa.map(pkh::package_info::split_ppa) {
Some(Ok((user, name))) => Some(pkh::package_info::ppa_to_base_url(user, name)),
Some(Err(e)) => {
error!("{e}");
std::process::exit(1);
}
None => None,
};
// Since pull is async, we need to block on it
if let Err(e) = rt.block_on(async {
let package_info = pkh::package_info::lookup(
package,
version,
series,
pocket,
dist,
base_url.as_deref(),
repository,
Some(&progress_callback),
)
.await?;
pkh::pull::pull(&package_info, None, Some(&progress_callback), *archive).await
}) {
pb.finish_and_clear();
error!("{}", e);
std::process::exit(1);
}
pb.finish_and_clear();
multi.remove(&pb);
info!("Done.");
}
Some(("chlog", sub_matches)) => {
let history_limit = sub_matches
.get_one::<usize>("limit")
.copied()
.unwrap_or(500);
let changelog_file = sub_matches
.get_one::<String>("file")
.map(|s| s.to_string())
.unwrap_or_else(|| "debian/changelog".to_string());
let cwd = current_dir_or_exit();
let version = sub_matches.get_one::<String>("version").map(|s| s.as_str());
let cli_series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
let kind = if sub_matches
.get_one::<bool>("backport")
.copied()
.unwrap_or(false)
{
pkh::changelog::EntryKind::Backport
} else if sub_matches.get_one::<bool>("nmu").copied().unwrap_or(false) {
pkh::changelog::EntryKind::Nmu
} else if sub_matches
.get_one::<bool>("rebuild")
.copied()
.unwrap_or(false)
{
pkh::changelog::EntryKind::Rebuild
} else {
pkh::changelog::EntryKind::Normal
};
// Determine target series: CLI flag > interactive selector > current changelog series
let target_series = if let Some(s) = cli_series {
Some(s.to_string())
} else {
let changelog_path = cwd.join(&changelog_file);
match rt.block_on(pkh::changelog::series_candidates(&changelog_path)) {
Some(pkh::changelog::SeriesCandidates::Choose {
options,
values,
default,
fallback,
}) => match pkh::ui::select_series(&options, &default) {
Ok(selected) => {
Some(pkh::changelog::selected_series(&options, &values, selected))
}
Err(e) => {
error!(
"Series selection failed: {}. Using current series '{}' instead.",
e, fallback
);
Some(fallback)
}
},
// Could not fetch the series list: use the current series
Some(pkh::changelog::SeriesCandidates::Keep(current)) => Some(current),
// No parsable changelog: leave the series decision to
// generate_entry
None => None,
}
};
let entry = match rt.block_on(pkh::changelog::generate_entry(
&changelog_file,
Some(&cwd),
version,
target_series.as_deref(),
kind,
history_limit,
)) {
Ok(entry) => entry,
Err(e) => {
error!("{}", e);
std::process::exit(1);
}
};
println!(
"Found package: {}, version: {}",
entry.package, entry.previous_version
);
println!("New version: {}", entry.new_version);
println!("Added new changelog entry to {}", entry.path.display());
let editor = match std::env::var("EDITOR") {
Ok(e) => e,
Err(_) => {
error!(
"No editor configured. Set the EDITOR environment variable \
(e.g. `EDITOR=nano` or `export EDITOR=vim`) and retry."
);
std::process::exit(1);
}
};
let _status = std::process::Command::new(&editor)
.current_dir(&cwd)
.arg(&changelog_file)
.status()
.map_err(|e| {
error!(
"Could not launch editor '{}': {}. \
Make sure it is installed and available on PATH.",
editor, e
);
std::process::exit(1);
});
}
Some(("build", sub_matches)) => {
let cwd = current_dir_or_exit();
interrupt::install();
let verbose = sub_matches
.get_one::<bool>("verbose")
.copied()
.unwrap_or(false);
// Live build view, unless --verbose (DebUi additionally disables
// itself when stdout is not a terminal)
let quiet = pkh::report::Quiet;
let live = if verbose {
None
} else {
Some(pkh::ui::deb::DebUi::new(&multi))
};
let view: &dyn pkh::report::BuildView = live
.as_ref()
.map(|v| v as &dyn pkh::report::BuildView)
.unwrap_or(&quiet);
let prompter = pkh::ui::prompt::TerminalPrompter;
let orig_source = match sub_matches.get_one::<String>("orig").map(String::as_str) {
Some("always") => pkh::build::OrigSourceMode::Always,
Some("never") => pkh::build::OrigSourceMode::Never,
_ => pkh::build::OrigSourceMode::Auto,
};
match pkh::build::build_source_package(pkh::build::BuildSourceOptions {
source: Some(cwd),
options: pkh::build::SourceBuildOptions {
orig_source,
..Default::default()
},
view,
prompter: &prompter,
}) {
Ok(output) => {
// The live view lists the artifacts itself when it
// renders; otherwise (verbose mode or non-TTY stdout)
// print them as plain lines.
if !view.is_enabled() {
for artifact in output.artifacts() {
println!(" {}", pkh::report::display_path(&artifact));
}
}
if output.signed {
println!("Package built and signed successfully!");
} else {
println!("Package built successfully (unsigned).");
}
}
Err(e) => {
// On Ctrl+C the interrupt watchdog owns the shutdown
// (see `pkh deb`): park here instead of racing it
if pkh::interrupt::interrupted() {
interrupt::wait_for_shutdown();
}
// The unmet-dependency diagnostics first, then the
// summary: the exact rendering the flow used to do.
if let Some(unmet) =
e.downcast_ref::<pkh::debian::deps::UnmetBuildDependencies>()
{
eprintln!("{}", unmet.0.message());
}
error!("{}", e);
// Unmet build dependencies/conflicts exit with status 3,
// like dpkg-buildpackage does.
if e.downcast_ref::<pkh::debian::deps::UnmetBuildDependencies>()
.is_some()
{
std::process::exit(3);
}
std::process::exit(1);
}
}
}
Some(("put", sub_matches)) => {
let cwd = current_dir_or_exit();
interrupt::install();
let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str());
let changes = sub_matches
.get_one::<String>("changes")
.map(std::path::PathBuf::from);
let force = sub_matches
.get_one::<bool>("force")
.copied()
.unwrap_or(false);
// Only PPA targets are implemented for now
let Some(ppa) = ppa else {
error!(
"pkh put needs a target: pass --ppa user/ppa_name \
(archive uploads are not supported yet)"
);
std::process::exit(1);
};
let view = pkh::ui::deb::DebUi::new(&multi);
let prompter = pkh::ui::prompt::TerminalPrompter;
let options = pkh::put::PutOptions {
ppa: ppa.to_string(),
changes,
force,
cwd,
view: &view,
prompter: &prompter,
};
if let Err(e) = rt.block_on(async { pkh::put::put(&options).await }) {
// On Ctrl+C the interrupt watchdog owns the shutdown (see
// `pkh deb`): park here instead of racing it
if pkh::interrupt::interrupted() {
interrupt::wait_for_shutdown();
}
error!("{}", e);
std::process::exit(1);
}
}
Some(("deb", sub_matches)) => {
let cwd = current_dir_or_exit();
// `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);
print!("{}", pkh::deb::session::render_session_list(&tree, &rows));
return;
}
// `pkh deb log`: print a recorded build's log (optionally
// following a live build); read-only too.
if let Some(("log", log_matches)) = sub_matches.subcommand() {
let tree = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
let id = log_matches.get_one::<String>("id").map(String::as_str);
let follow = log_matches.get_flag("follow");
let record = match pkh::deb::session::find_record(
&pkh::deb::session::sessions_root(),
&tree,
id,
) {
Ok(record) => record,
Err(e) => {
error!("{e}");
std::process::exit(1);
}
};
let mut out = std::io::stdout();
if let Err(e) = pkh::deb::session::print_record_log(&record, follow, &mut out) {
error!(
"cannot print the build log of {}: {e}",
record.root().display()
);
std::process::exit(1);
}
return;
}
// `pkh deb profile`: print a recorded build's per-phase
// timing profile; read-only too.
if let Some(("profile", profile_matches)) = sub_matches.subcommand() {
let tree = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
let id = profile_matches.get_one::<String>("id").map(String::as_str);
let record = match pkh::deb::session::find_record(
&pkh::deb::session::sessions_root(),
&tree,
id,
) {
Ok(record) => record,
Err(e) => {
error!("{e}");
std::process::exit(1);
}
};
print!("{}", pkh::deb::session::render_record_profile(&record));
return;
}
// Ctrl+C during the build must say what happened and release the
// ephemeral chroot instead of dying on the default disposition.
// The live view (when enabled) registers its own reporter on top
// of this to clear the widget first.
interrupt::install();
let series = sub_matches.get_one::<String>("series").cloned();
let pocket = sub_matches.get_one::<String>("pocket").cloned();
let arch = sub_matches.get_one::<String>("arch").cloned();
let cross = sub_matches
.get_one::<bool>("cross")
.copied()
.unwrap_or(false);
let ppa: Vec<String> = sub_matches
.get_many::<String>("ppa")
.map(|v| v.cloned().collect())
.unwrap_or_default();
let inject: Vec<String> = sub_matches
.get_many::<String>("inject")
.map(|v| v.cloned().collect())
.unwrap_or_default();
let mode: Option<&str> = sub_matches.get_one::<String>("mode").map(|s| s.as_str());
let mode: Option<pkh::deb::BuildMode> = match mode {
Some("local") => Some(pkh::deb::BuildMode::Local),
_ => None,
};
let verbose = sub_matches
.get_one::<bool>("verbose")
.copied()
.unwrap_or(false);
let jobs = sub_matches.get_one::<String>("jobs").map(|s| s.as_str());
let jobs = jobs.map(|j| {
j.parse::<usize>().unwrap_or_else(|_| {
error!("Invalid --jobs value '{}': expected a positive integer", j);
std::process::exit(1);
})
});
// 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,
};
// Live build view, unless --verbose (DebUi additionally disables
// itself when stdout is not a terminal)
let quiet = pkh::report::Quiet;
let live = if verbose {
None
} else {
Some(pkh::ui::deb::DebUi::new(&multi))
};
let view: &dyn pkh::report::BuildView = live
.as_ref()
.map(|v| v as &dyn pkh::report::BuildView)
.unwrap_or(&quiet);
let result = rt.block_on(async {
pkh::deb::build_binary_package(pkh::deb::DebBuildOptions {
arch,
series,
pocket,
mirror: sub_matches.get_one::<String>("mirror").cloned(),
cwd: Some(cwd.clone()),
cross,
mode,
ppa,
inject,
jobs,
incremental,
ccache: !sub_matches.get_flag("no-ccache"),
view,
..Default::default()
})
.await
});
match result {
Ok(_) => info!("Done."),
Err(e) => {
// On Ctrl+C the interrupt watchdog owns the shutdown: it
// has already shown the notice, is releasing the build
// resources, and will exit with 130 — park here instead
// of racing it with another exit.
if pkh::interrupt::interrupted() {
interrupt::wait_for_shutdown();
}
error!("{}", e);
std::process::exit(1);
}
}
}
Some(("test", sub_matches)) => {
let path = sub_matches
.get_one::<String>("path")
.map(std::path::PathBuf::from);
// Read-only listing of the declared tests; never boots a
// testbed.
if sub_matches.get_flag("list_tests") {
let tree = path.unwrap_or_else(current_dir_or_exit);
match pkh::test::list_tests(&tree) {
Ok(listing) => print!("{listing}"),
Err(e) => {
error!("{e}");
std::process::exit(2);
}
}
return;
}
// Ctrl+C must release the ephemeral chroot instead of dying
// on the default disposition (the deb pattern).
interrupt::install();
let mode = match sub_matches.get_one::<String>("mode").map(String::as_str) {
None => None,
Some(value) => match pkh::test::TestMode::parse(value) {
Some(mode) => Some(mode),
None => {
error!("Invalid --mode value '{value}': expected chroot or local");
std::process::exit(1);
}
},
};
let (fail_on_skip, fail_on_no_tests) =
match sub_matches.get_one::<String>("fail_on").map(String::as_str) {
None => (false, false),
Some(list) => {
let mut skip = false;
let mut no_tests = false;
for trigger in list.split(',').map(str::trim).filter(|t| !t.is_empty()) {
match trigger {
"skip" => skip = true,
"no-tests" => no_tests = true,
other => {
error!(
"Unknown --fail-on trigger '{other}': \
expected skip or no-tests"
);
std::process::exit(1);
}
}
}
(skip, no_tests)
}
};
// Live build view, unless --verbose (DebUi additionally
// disables itself when stdout is not a terminal).
let quiet = pkh::report::Quiet;
let live = if sub_matches.get_flag("verbose") {
None
} else {
Some(pkh::ui::deb::DebUi::new(&multi))
};
let view: &dyn pkh::report::BuildView = live
.as_ref()
.map(|v| v as &dyn pkh::report::BuildView)
.unwrap_or(&quiet);
let options = pkh::test::TestOptions {
tree: path,
series: sub_matches.get_one::<String>("series").cloned(),
dist: sub_matches.get_one::<String>("dist").cloned(),
arch: sub_matches.get_one::<String>("arch").cloned(),
pocket: sub_matches.get_one::<String>("pocket").cloned(),
ppa: sub_matches
.get_many::<String>("ppa")
.map(|v| v.cloned().collect())
.unwrap_or_default(),
inject: sub_matches
.get_many::<String>("inject")
.map(|v| v.cloned().collect())
.unwrap_or_default(),
mode,
image: sub_matches
.get_one::<String>("image")
.map(std::path::PathBuf::from),
cpus: sub_matches.get_one::<u32>("cpus").copied(),
ram_size: sub_matches
.get_one::<u32>("ram-size")
.map(|v| v.to_string()),
debs: sub_matches
.get_many::<String>("debs")
.map(|v| v.map(std::path::PathBuf::from).collect())
.unwrap_or_default(),
no_build: sub_matches.get_flag("no_build"),
build: sub_matches.get_flag("build"),
test_names: sub_matches
.get_many::<String>("test_name")
.map(|v| v.cloned().collect())
.unwrap_or_default(),
skip_tests: sub_matches
.get_many::<String>("skip_test")
.map(|v| v.cloned().collect())
.unwrap_or_default(),
setup_commands: sub_matches
.get_many::<String>("setup_commands")
.map(|v| v.cloned().collect())
.unwrap_or_default(),
apt_upgrade: sub_matches.get_flag("apt_upgrade"),
shell: sub_matches.get_flag("shell"),
shell_fail: sub_matches.get_flag("shell_fail"),
timeout: sub_matches.get_one::<u64>("timeout").copied(),
timeout_factor: sub_matches.get_one::<f64>("timeout_factor").copied(),
fail_on_skip,
fail_on_no_tests,
json: sub_matches.get_flag("json"),
verbose: sub_matches.get_flag("verbose"),
view,
};
let json = options.json;
match rt.block_on(pkh::test::run(options)) {
Ok(report) => {
// The per-test report is the deliverable; the live
// view carries phases and the outcome banner only.
if json {
println!("{}", report.render_json());
} else {
print!("{}", report.render_text());
}
std::process::exit(report.exit_code(fail_on_skip, fail_on_no_tests));
}
Err(e) => {
// On Ctrl+C the interrupt watchdog owns the shutdown
// (see `pkh deb`): park here instead of racing it.
if pkh::interrupt::interrupted() {
interrupt::wait_for_shutdown();
}
error!("{e}");
std::process::exit(2);
}
}
}
Some(("prune", sub_matches)) => {
let dry_run = sub_matches
.get_one::<bool>("dry_run")
.copied()
.unwrap_or(false);
let all = sub_matches.get_one::<bool>("all").copied().unwrap_or(false);
let options = pkh::prune::PruneOptions { dry_run, all };
match pkh::prune::prune(options) {
Ok(report) => {
if report.is_empty() {
info!("Nothing to prune.");
} else {
let action = if report.dry_run {
"Would remove"
} else {
"Removed"
};
for path in &report.removed {
info!("{} {}", action, path.display());
}
for (path, err) in &report.failed {
error!("Failed to remove {}: {}", path.display(), err);
}
if report.dry_run {
info!(
"(dry run) {} item(s) would be removed.",
report.removed.len()
);
} else {
info!(
"Pruned {} item(s){} ({} failure(s)).",
report.removed.len(),
if all {
" including cached chroot tarballs"
} else {
""
},
report.failed.len()
);
}
}
if !report.failed.is_empty() {
std::process::exit(1);
}
}
Err(e) => {
error!("{}", e);
std::process::exit(1);
}
}
}
Some(("lint", sub_matches)) => {
if sub_matches.get_flag("list_tags") {
print!("{}", pkh::lint::list_tags());
std::process::exit(0);
}
let path = sub_matches
.get_one::<String>("path")
.map(std::path::PathBuf::from)
.unwrap_or_else(current_dir_or_exit);
let fail_on = match pkh::lint::output::parse_fail_on(
sub_matches
.get_one::<String>("fail_on")
.map(String::as_str)
.unwrap_or("error"),
) {
Ok(levels) => levels,
Err(e) => {
error!("{}", e);
std::process::exit(2);
}
};
let options = pkh::lint::LintOptions {
path,
native: sub_matches.get_flag("native"),
fail_on,
info: sub_matches.get_flag("info"),
display_info: sub_matches.get_flag("display_info"),
pedantic: sub_matches.get_flag("pedantic"),
experimental: sub_matches.get_flag("experimental"),
show_overrides: sub_matches.get_flag("show_overrides"),
suppress_tags: sub_matches
.get_one::<String>("suppress_tags")
.map(|list| {
list.split(',')
.map(str::trim)
.filter(|tag| !tag.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
only_checks: sub_matches
.get_many::<String>("check")
.map(|values| values.cloned().collect())
.unwrap_or_default(),
repack: sub_matches.get_flag("repack"),
json: sub_matches.get_flag("json"),
color: match sub_matches.get_one::<String>("color").map(String::as_str) {
Some("always") => pkh::lint::output::ColorMode::Always,
Some("never") => pkh::lint::output::ColorMode::Never,
_ => pkh::lint::output::ColorMode::Auto,
},
dist: sub_matches.get_one::<String>("dist").cloned(),
series: sub_matches.get_one::<String>("series").cloned(),
};
match pkh::lint::run(&options) {
Ok(report) => {
if options.json {
println!("{}", pkh::lint::output::render_json(&report, &options));
} else {
print!("{}", pkh::lint::output::render_text(&report, &options));
}
std::process::exit(pkh::lint::output::exit_code(&report, &options));
}
Err(e) => {
error!("{}", e);
std::process::exit(2);
}
}
}
_ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"),
}
}
#[cfg(test)]
mod tests {
use super::interrupt;
/// End-to-end check of the whole sequence: installed handler → self-pipe
/// → watchdog → notice + hooks → exit status 130. The sequence ends in
/// `libc::_exit`, so it cannot be exercised in-process: this test
/// re-spawns the test binary in child mode (env var), where the test
/// installs the handler and raises SIGINT at itself.
#[test]
fn sigint_sequence_prints_the_notice_and_exits_130() {
const CHILD_ENV: &str = "PKH_SIGINT_TEST_CHILD";
if std::env::var(CHILD_ENV).is_ok() {
// Child mode: install, register a pending hook, then interrupt
// ourselves. If the sequence never runs, the sleep below turns
// the failure into a wrong (zero) exit code instead of a hang.
interrupt::install();
// Alive until the watchdog drains it: a dropped guard would
// deregister the hook and the drain would run empty
let _hook = pkh::interrupt::register_cleanup_hook(Box::new(|| ()));
// SAFETY: kill(2) to our own process with SIGINT
unsafe {
libc::kill(libc::getpid(), libc::SIGINT);
}
std::thread::sleep(std::time::Duration::from_secs(30));
std::process::exit(0);
}
let exe = std::env::current_exe().expect("locate the test executable");
let output = std::process::Command::new(exe)
// --nocapture: libtest's capture buffer would otherwise swallow
// the watchdog's notice (threads spawned during a test inherit
// the capture), and the process exits before the harness prints
// anything it captured
.args([
"--exact",
"tests::sigint_sequence_prints_the_notice_and_exits_130",
"--test-threads=1",
"--nocapture",
])
.env(CHILD_ENV, "1")
.output()
.expect("re-spawn the test binary");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(130), "child stderr:\n{stderr}");
assert!(
stderr.contains("CTRL+C: Build interrupted by user."),
"child stderr:\n{stderr}"
);
}
}