Compare commits
12
Commits
8250a0e3b1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b52f8e9e9 | ||
|
|
6df490cf9a | ||
|
|
f5b7704647 | ||
|
|
4c1edc7dcd
|
||
|
|
9a66f8f7df | ||
|
|
7a6337e1cb | ||
|
|
9186bbbe51 | ||
|
|
681fa3d687 | ||
|
|
cc5bbd2297 | ||
|
|
5a1c1672cd | ||
|
|
adde0ee977 | ||
|
|
84405a6762 |
Binary file not shown.
|
Before Width: | Height: | Size: 696 KiB After Width: | Height: | Size: 799 KiB |
@@ -138,7 +138,7 @@ jobs:
|
||||
- name: Set up container image
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y nodejs sudo curl wget ca-certificates
|
||||
apt-get install -y nodejs sudo curl wget ca-certificates build-essential
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install build dependencies
|
||||
|
||||
@@ -48,6 +48,8 @@ Rules:
|
||||
- `prune`, `package_info` — remaining subcommand modules
|
||||
- `context` — build contexts: local, ssh, chroot/schroot, unshare
|
||||
(`src/context/`)
|
||||
- `interrupt` — Ctrl+C interception and interrupt-time cleanup
|
||||
(`src/interrupt.rs`)
|
||||
- `debian` — Debian format primitives: control, versions, checksums,
|
||||
arch (`src/debian/`)
|
||||
- `apt`, `launchpad`, `distro_info`, `quirks` — archive/distro
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
|
||||
## Installation
|
||||
|
||||
No distribution channel is published yet; build from source:
|
||||
From crates.io:
|
||||
|
||||
```
|
||||
cargo install pkh
|
||||
```
|
||||
|
||||
Or build from source (the same system packages are needed either way):
|
||||
|
||||
```
|
||||
sudo apt install pkg-config libssl-dev libgpg-error-dev libgpgme-dev
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: pkh
|
||||
description: 'Drive pkh, a Debian/Ubuntu packaging helper: pull source packages, generate changelog entries, build .dsc/.deb, lint, and upload to a PPA. Use it whenever the task touches Debian or Ubuntu packaging: patching an existing package, preparing an SRU, backport or NMU, scaffolding a new .deb, rebuilding for a PPA, or uploading a source package. Trigger on "update the changelog", "package this", or a bare package name, even when the user never mentions Debian.'
|
||||
---
|
||||
|
||||
# pkh
|
||||
|
||||
`pkh` wraps the Debian packaging toolchain (`dch`, `dpkg-buildpackage`,
|
||||
`sbuild`, `dpkg-source`, `quilt`, `lintian`, PPA uploads) in one CLI.
|
||||
The subcommands share one set of option names, so `-s` always targets
|
||||
the series and `--ppa` always names the PPA. Each step also does more
|
||||
than the raw tool it replaces: `pull` fetches the orig tarball with the
|
||||
source, `chlog` commits the entry it writes, `deb` sets up a chroot and
|
||||
installs the build dependencies.
|
||||
|
||||
Check the install with `pkh --version`. Each command lists its flags
|
||||
with `pkh <command> --help`, so check there instead of guessing. pkh
|
||||
shells out to host tools (git, dpkg-dev, quilt, mmdebstrap, lintian,
|
||||
pristine-tar, schroot, ...). Install the ones your workflow uses, or
|
||||
use the classic snap, which carries them.
|
||||
|
||||
## Shared options
|
||||
|
||||
| Option | Meaning |
|
||||
|---|---|
|
||||
| `-d, --dist <dist>` | Target distribution, `debian` or `ubuntu` |
|
||||
| `-s, --series <series>` | Target series, for example `resolute` or `noble` |
|
||||
| `-v, --version <version>` | Target package version |
|
||||
| `-a, --arch <arch>` | Target architecture, for example `amd64` or `riscv64` |
|
||||
| `-p, --pocket <pocket>` | Distribution pocket: `updates`, `security`, `proposed` |
|
||||
| `--ppa <user/ppa>` | Act on the named PPA |
|
||||
|
||||
Defaults come from the host vendor, its development series, and its
|
||||
architecture. When the target differs, pass the flags: packaging for
|
||||
Ubuntu on a Debian host needs `-d ubuntu`, and a series or architecture
|
||||
that differs from the host needs `-s` or `-a`.
|
||||
|
||||
## Patch an Ubuntu package
|
||||
|
||||
```
|
||||
pkh pull hello # source and orig tarball; add -d ubuntu off an Ubuntu host
|
||||
# edit the package, committing each patch to git
|
||||
pkh chlog # generates the entry, opens it for editing, commits it
|
||||
git add debian/changelog && git commit -m "d/changelog"
|
||||
pkh build # source package, written next to the tree
|
||||
pkh deb # binary build in a chroot with build deps installed
|
||||
pkh lint # lintian plus pkh-native checks
|
||||
pkh put --ppa user/hello_xxx # uploads the .changes file from the build
|
||||
git push xxx user-fork # push the branch to your fork
|
||||
```
|
||||
|
||||
Run `pkh chlog` and `pkh build` from the root of the source tree; they
|
||||
act on the package in the current directory.
|
||||
|
||||
## Command reference
|
||||
|
||||
- `pkh new [name]` scaffolds a buildable source package. `--lang`
|
||||
picks the build system (`rust`, `python`, `meson`, `cmake`,
|
||||
`autotools`, `go`, `shell`, `makefile`); `--source <PATH>` packages
|
||||
existing sources instead. `--upstream-version` and `--revision` set
|
||||
the version. `--description`, `--homepage`, `--license <SPDX>`,
|
||||
`--command`, `--maintainer "Name <email>"`, and `--depends` fill in
|
||||
the package metadata, with the maintainer defaulting to
|
||||
`DEBFULLNAME`/`DEBEMAIL` and then git config. `--quilt` and
|
||||
`--native` choose the source format; `--orig-from
|
||||
release|git|path|snapshot` and `--orig-path` control the orig
|
||||
tarball. The changelog starts as `UNRELEASED`; `--release` targets
|
||||
`--series` instead. `--defaults` answers every remaining question
|
||||
with its default, which keeps the run non-interactive.
|
||||
|
||||
- `pkh pull <package>` fetches a source package from the archive or
|
||||
git. `--archive` skips git. `--ppa user/ppa` and `--repository
|
||||
<suite-url>` pull from a PPA or an external flat repository instead.
|
||||
`-d`, `-s`, `-v`, and `-p` target an exact source.
|
||||
|
||||
- `pkh chlog` generates the changelog entry from the commits since the
|
||||
last version tag, opens it for editing, and commits it. `--backport`,
|
||||
`--nmu`, and `--rebuild` apply the matching numbering scheme
|
||||
(`3.1-1ubuntu2~24.04.1`, `1.0-1.1`, `1.0-1build1`); `-v` sets an
|
||||
explicit version instead.
|
||||
|
||||
- `pkh build` produces the .dsc. `--orig auto|always|never` controls
|
||||
whether the upload includes the orig tarball; the default, `auto`,
|
||||
includes it only when the upstream version changed.
|
||||
|
||||
- `pkh deb` builds the binary packages in an isolated context with the
|
||||
build dependencies installed. `--ppa` (repeatable) adds dependency
|
||||
sources, `--inject <package|.deb>` preinstalls a package, and `-j`
|
||||
caps parallel jobs. `--cross` cross-compiles instead of using
|
||||
qemu-binfmt, but most packages cannot cross-compile, so prefer qemu.
|
||||
Leave `--mode` unset unless you need a specific build context.
|
||||
|
||||
- `pkh lint [path]` runs lintian plus the pkh-native checks. `--json`
|
||||
emits a machine-readable report and `--list-tags` prints the native
|
||||
tag catalog. `--fail-on` sets the severities that fail the run
|
||||
(errors by default), `--suppress-tags` ignores tags, `--check` runs a
|
||||
single native check, and `--info`, `--pedantic`, and
|
||||
`--experimental` add detail. `--repack` packs the tree fresh instead
|
||||
of reusing the existing build output.
|
||||
|
||||
- `pkh put [changes]` uploads a .changes file to `--ppa user/ppa`.
|
||||
With no argument it uploads the .changes from this package's last
|
||||
build, found next to the source tree. `--force` re-uploads a file
|
||||
that was already uploaded.
|
||||
|
||||
- `pkh prune` removes build artifacts and caches. Run it with
|
||||
`--dry-run` first to list them. `--all` also deletes the cached
|
||||
chroot tarballs, which take long to download again, so use it when
|
||||
you need the disk space.
|
||||
|
||||
## Notes for agent runs
|
||||
|
||||
- Pass `-d`, `-s`, and `-a` whenever the target differs from the host,
|
||||
so runs are reproducible.
|
||||
- Keep runs non-interactive. Pass explicit flags, use `pkh new
|
||||
--defaults`, and set `EDITOR` before `pkh chlog` (`EDITOR=true` keeps
|
||||
the generated text). Commands may ask short questions on the
|
||||
terminal; flags avoid most prompts.
|
||||
- Pass `RUST_LOG=debug` for pkh's own logs. `--verbose` on `pkh build`
|
||||
and `pkh deb` prints raw tool output instead of the live view.
|
||||
- pkh writes the build artifacts (.dsc, .changes, logs) next to the
|
||||
source tree. `pkh put` finds them without arguments, and `pkh prune`
|
||||
removes them again.
|
||||
- Run `pkh lint` before `pkh put`. It exits nonzero when findings reach
|
||||
the `--fail-on` level, which defaults to errors.
|
||||
- pkh intercepts Ctrl+C, runs its cleanup hooks, and exits with status
|
||||
130. `pkh prune` removes anything left over.
|
||||
|
||||
The upstream repository is https://git.vhaudiquet.fr/vhaudiquet/pkh.
|
||||
Its README has longer workflow examples.
|
||||
+65
-2
@@ -137,7 +137,9 @@ pub async fn generate_entry(
|
||||
enum Bump {
|
||||
/// Regular upload: increment the trailing number
|
||||
Normal,
|
||||
/// Ubuntu upload: `1.0-9` becomes `1.0-9ubuntu1`
|
||||
/// Ubuntu upload: `1.0-9` becomes `1.0-9ubuntu1`; a trailing
|
||||
/// no-change-rebuild marker is dropped first (`1.0-9build1` becomes
|
||||
/// `1.0-9ubuntu1`)
|
||||
Ubuntu,
|
||||
/// Non-maintainer upload: `1.0-1` becomes `1.0-1.1`, native `1.0`
|
||||
/// becomes `1.0+nmu1`
|
||||
@@ -158,7 +160,7 @@ fn compute_new_version(
|
||||
bump: Bump,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
match bump {
|
||||
Bump::Ubuntu => increment_suffix(old_version, "ubuntu"),
|
||||
Bump::Ubuntu => increment_suffix(strip_build_suffix(old_version), "ubuntu"),
|
||||
Bump::Rebuild => increment_suffix(old_version, "build"),
|
||||
Bump::Nmu => {
|
||||
if old_version.contains('-') {
|
||||
@@ -177,6 +179,19 @@ fn compute_new_version(
|
||||
}
|
||||
}
|
||||
|
||||
/// The version an Ubuntu upload is numbered from: a trailing
|
||||
/// no-change-rebuild marker is dropped, because a real change on top of a
|
||||
/// rebuild replaces the marker rather than appending to it — `X-2build1`
|
||||
/// becomes `X-2ubuntu1`, where an appended `X-2build1ubuntu1` would
|
||||
/// misrepresent the lineage and sort below `X-2ubuntu1`
|
||||
fn strip_build_suffix(version: &str) -> &str {
|
||||
let stem = version.trim_end_matches(|c: char| c.is_ascii_digit());
|
||||
match stem.strip_suffix("build") {
|
||||
Some(base) if stem.len() < version.len() => &version[..base.len()],
|
||||
_ => version,
|
||||
}
|
||||
}
|
||||
|
||||
/// The version bump a regular ([`EntryKind::Normal`]) upload gets, derived
|
||||
/// from the vendor of the target series: Ubuntu series number their uploads
|
||||
/// the Ubuntu way (`1.0-1` becomes `1.0-1ubuntu1`), everything else —
|
||||
@@ -1139,6 +1154,22 @@ mod tests {
|
||||
compute_new_version("15.2.0-9ubuntu1", Bump::Ubuntu).unwrap(),
|
||||
"15.2.0-9ubuntu2"
|
||||
);
|
||||
// Ubuntu upload on top of a rebuild drops the buildN marker:
|
||||
// appending would give 15.2.0-9build1ubuntu1, which sorts below
|
||||
// the proper 15.2.0-9ubuntu1
|
||||
assert_eq!(
|
||||
compute_new_version("15.2.0-9build1", Bump::Ubuntu).unwrap(),
|
||||
"15.2.0-9ubuntu1"
|
||||
);
|
||||
assert_eq!(
|
||||
compute_new_version("15.2.0-9ubuntu1build1", Bump::Ubuntu).unwrap(),
|
||||
"15.2.0-9ubuntu2"
|
||||
);
|
||||
// Native packages
|
||||
assert_eq!(
|
||||
compute_new_version("15.2.0build1", Bump::Ubuntu).unwrap(),
|
||||
"15.2.0ubuntu1"
|
||||
);
|
||||
|
||||
// No change rebuild
|
||||
assert_eq!(
|
||||
@@ -1395,6 +1426,38 @@ mod tests {
|
||||
assert_eq!(entry.new_version, "1.0-1ubuntu2");
|
||||
}
|
||||
|
||||
/// An Ubuntu upload of a package whose changelog carries a rebuild
|
||||
/// version drops the buildN marker: 1.0-1build1 numbers the next entry
|
||||
/// 1.0-1ubuntu1 (1.0-1build1ubuntu1 would sort below 1.0-1ubuntu1).
|
||||
/// Relies on the host distro-info data listing noble (see the
|
||||
/// distro_info tests). The git repo provides the maintainer identity:
|
||||
/// DEBFULLNAME/DEBEMAIL are process-global and other tests mutate them
|
||||
/// in parallel.
|
||||
#[tokio::test]
|
||||
async fn test_generate_entry_ubuntu_series_numbering_after_rebuild() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_dir = temp_dir.path();
|
||||
setup_repo(repo_dir);
|
||||
let changelog_path = repo_dir.join("debian/changelog");
|
||||
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
|
||||
std::fs::write(
|
||||
&changelog_path,
|
||||
"mypackage (1.0-1build1) noble; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let entry = generate_entry(
|
||||
"debian/changelog",
|
||||
Some(repo_dir),
|
||||
None,
|
||||
Some("noble"),
|
||||
EntryKind::Normal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(entry.new_version, "1.0-1ubuntu1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_maintainer_info() {
|
||||
let _identity = IDENTITY_LOCK.blocking_lock();
|
||||
|
||||
@@ -70,6 +70,12 @@ pub trait ContextDriver {
|
||||
fn read_file(&self, path: &Path) -> io::Result<String>;
|
||||
fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
|
||||
fn exists(&self, path: &Path) -> io::Result<bool>;
|
||||
/// Check if a path is a directory inside the context
|
||||
///
|
||||
/// Distinct from [`ContextDriver::exists`] because paths returned by
|
||||
/// [`ContextDriver::list_files`] are context-relative and can only be
|
||||
/// classified through the context, never with a host-side stat.
|
||||
fn is_dir(&self, path: &Path) -> io::Result<bool>;
|
||||
|
||||
/// Clean up any resources held by the driver (e.g. unmount overlay filesystems).
|
||||
/// Called before the chroot directory is removed.
|
||||
@@ -313,6 +319,15 @@ impl Context {
|
||||
self.driver().as_ref().unwrap().exists(path)
|
||||
}
|
||||
|
||||
/// Check if a path is a directory inside context
|
||||
///
|
||||
/// Paths returned by [`Context::list_files`] are context-relative
|
||||
/// (e.g. rooted inside the chroot for an unshare context): whether they
|
||||
/// are directories can only be decided through the context.
|
||||
pub fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||
self.driver().as_ref().unwrap().is_dir(path)
|
||||
}
|
||||
|
||||
/// Clean up any resources held by the driver (e.g. unmount overlay filesystems).
|
||||
/// Called before the chroot directory is removed.
|
||||
pub fn cleanup(&self) -> io::Result<()> {
|
||||
|
||||
@@ -155,6 +155,10 @@ impl ContextDriver for LocalDriver {
|
||||
fn exists(&self, path: &Path) -> io::Result<bool> {
|
||||
Ok(path.exists())
|
||||
}
|
||||
|
||||
fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||
Ok(path.is_dir())
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
|
||||
|
||||
@@ -385,6 +385,40 @@ mod tests {
|
||||
assert!(!dest.join("src/.svn").exists());
|
||||
}
|
||||
|
||||
/// The unshare driver maps context-relative paths onto the chroot root
|
||||
/// on the host: `is_dir` must answer through that mapping (a host-side
|
||||
/// stat of the unmapped path sees nothing), which is what lets the deb
|
||||
/// package-directory search classify staged entries.
|
||||
#[test]
|
||||
fn test_unshare_is_dir_maps_through_the_chroot_root() {
|
||||
let chroot = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(chroot.path().join("tmp/work/tree/debian")).unwrap();
|
||||
fs::write(chroot.path().join("tmp/work/orig.tar.xz"), "tar").unwrap();
|
||||
|
||||
let base = Context::new(ContextConfig::Local).unwrap();
|
||||
let ctx = Context::with_parent(
|
||||
ContextConfig::Unshare {
|
||||
path: chroot.path().to_string_lossy().to_string(),
|
||||
parent: None,
|
||||
},
|
||||
Arc::new(base),
|
||||
);
|
||||
|
||||
assert!(ctx.is_dir(std::path::Path::new("/tmp/work/tree")).unwrap());
|
||||
assert!(
|
||||
ctx.exists(std::path::Path::new("/tmp/work/tree/debian"))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!ctx.is_dir(std::path::Path::new("/tmp/work/orig.tar.xz"))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!ctx.exists(std::path::Path::new("/tmp/work/missing"))
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
/// The overlay-mount path exposes the tree verbatim, so pruning happens
|
||||
/// after the fact: nested VCS metadata must be removed recursively.
|
||||
#[test]
|
||||
|
||||
@@ -296,6 +296,16 @@ impl ContextDriver for SchrootDriver {
|
||||
)?;
|
||||
Ok(status.success())
|
||||
}
|
||||
|
||||
fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||
let status = self.run(
|
||||
"test",
|
||||
&["-d".to_string(), path.to_string_lossy().to_string()],
|
||||
&[],
|
||||
None,
|
||||
)?;
|
||||
Ok(status.success())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -306,6 +306,14 @@ impl ContextDriver for SshDriver {
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||
let sess = connect_ssh(&self.host, self.user.as_deref(), self.port)?;
|
||||
let sftp = sess.sftp().map_err(io::Error::other)?;
|
||||
// Same error tolerance as `exists`: an unreachable path is not a
|
||||
// directory, and the caller decides what absence means.
|
||||
Ok(sftp.stat(path).map(|stat| stat.is_dir()).unwrap_or(false))
|
||||
}
|
||||
}
|
||||
|
||||
impl SshDriver {
|
||||
|
||||
@@ -356,6 +356,11 @@ impl ContextDriver for UnshareDriver {
|
||||
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
|
||||
self.parent().exists(&host_path)
|
||||
}
|
||||
|
||||
fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
|
||||
Ok(host_path.is_dir())
|
||||
}
|
||||
}
|
||||
|
||||
impl UnshareDriver {
|
||||
|
||||
+80
-271
@@ -1,139 +1,24 @@
|
||||
use crate::context::{self, Context, ContextConfig};
|
||||
use crate::deb::{Phase, enter_phase};
|
||||
use crate::interrupt::CleanupHookGuard;
|
||||
use crate::report::BuildView;
|
||||
use directories::ProjectDirs;
|
||||
use std::any::Any;
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tar::Archive;
|
||||
use xz2::read::XzDecoder;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process-global cleanup hooks
|
||||
//
|
||||
// On Ctrl-C, the SIGINT handler in `ui::deb` restores the terminal and then
|
||||
// `libc::_exit(130)`s, skipping all destructors — including
|
||||
// [`EphemeralContextGuard::drop`] — which leaks the freshly bootstrapped
|
||||
// chroot under /tmp together with its bind-mounted /proc and any overlayfs
|
||||
// mounts. To make interrupt-time cleanup possible anyway, resources register
|
||||
// a self-contained cleanup hook here; the SIGINT handler drains and runs the
|
||||
// registry right before exiting.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A boxed, send-safe cleanup hook body
|
||||
type CleanupFn = Box<dyn Fn() + Send>;
|
||||
|
||||
/// A pending cleanup hook together with its registry id
|
||||
struct CleanupHook {
|
||||
id: u64,
|
||||
f: CleanupFn,
|
||||
}
|
||||
|
||||
/// Registry of cleanup hooks waiting to run at interrupt time
|
||||
static CLEANUP_HOOKS: Mutex<Vec<CleanupHook>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Source of the registry ids used to deregister a specific hook
|
||||
static NEXT_CLEANUP_HOOK_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// Register a hook to be run by [`run_cleanup_hooks`] (i.e. when the process
|
||||
/// is interrupted), returning a guard whose drop deregisters the hook again
|
||||
fn register_cleanup_hook(f: CleanupFn) -> CleanupHookGuard {
|
||||
let id = NEXT_CLEANUP_HOOK_ID.fetch_add(1, Ordering::Relaxed);
|
||||
CLEANUP_HOOKS.lock().unwrap().push(CleanupHook { id, f });
|
||||
CleanupHookGuard(id)
|
||||
}
|
||||
|
||||
/// RAII handle to a registered cleanup hook: dropping it (or an explicit
|
||||
/// [`CleanupHookGuard::deregister`]) removes the hook from the registry so
|
||||
/// the interrupt path can no longer run it
|
||||
struct CleanupHookGuard(u64);
|
||||
|
||||
impl CleanupHookGuard {
|
||||
/// Registry id of the hook (used to filter the registry in tests)
|
||||
#[cfg(test)]
|
||||
fn id(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Remove the hook from the registry; returns whether it was still pending
|
||||
fn deregister(&mut self) -> bool {
|
||||
deregister_cleanup_hook(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CleanupHookGuard {
|
||||
fn drop(&mut self) {
|
||||
deregister_cleanup_hook(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a hook from the registry; returns whether it was still pending
|
||||
fn deregister_cleanup_hook(id: u64) -> bool {
|
||||
let mut hooks = CLEANUP_HOOKS.lock().unwrap();
|
||||
let len_before = hooks.len();
|
||||
hooks.retain(|hook| hook.id != id);
|
||||
hooks.len() != len_before
|
||||
}
|
||||
|
||||
/// Drain and run every registered cleanup hook exactly once
|
||||
///
|
||||
/// Called from the SIGINT handler right before the process exits. Draining
|
||||
/// uses `try_lock` with a bounded retry instead of a blocking lock: if the
|
||||
/// signal interrupted the main thread while it held [`CLEANUP_HOOKS`] (inside
|
||||
/// register/deregister), blocking on the same non-recursive mutex from the
|
||||
/// handler would deadlock the process. Timing out therefore skips cleanup
|
||||
/// (leaking, as before this registry existed) rather than hanging.
|
||||
pub(crate) fn run_cleanup_hooks() {
|
||||
run_drained_hooks(drain_cleanup_hooks());
|
||||
}
|
||||
|
||||
/// Take every pending hook out of the registry, waiting at most ~1s for the
|
||||
/// registry lock (see [`run_cleanup_hooks`] for why this must not block forever)
|
||||
fn drain_cleanup_hooks() -> Vec<CleanupHook> {
|
||||
const RETRIES: usize = 200;
|
||||
const RETRY_DELAY: Duration = Duration::from_millis(5);
|
||||
|
||||
for _ in 0..RETRIES {
|
||||
if let Ok(mut hooks) = CLEANUP_HOOKS.try_lock() {
|
||||
return std::mem::take(&mut *hooks);
|
||||
}
|
||||
std::thread::sleep(RETRY_DELAY);
|
||||
}
|
||||
log::error!("Timed out waiting for the cleanup hook registry; skipping interrupt cleanup");
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Run drained hooks one by one, isolating panics so that one failing hook
|
||||
/// cannot skip the remaining ones
|
||||
fn run_drained_hooks(hooks: Vec<CleanupHook>) {
|
||||
for CleanupHook { id, f } in hooks {
|
||||
// Hooks are arbitrary user code; assert unwind safety so they can be
|
||||
// run inside a catching context
|
||||
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
||||
log::error!("Cleanup hook {id} panicked: {}", panic_message(&panic));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort message extraction from a panic payload
|
||||
fn panic_message(panic: &(dyn Any + Send)) -> String {
|
||||
if let Some(s) = panic.downcast_ref::<&str>() {
|
||||
(*s).to_string()
|
||||
} else if let Some(s) = panic.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"non-string panic payload".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interrupt-time chroot cleanup
|
||||
//
|
||||
// On Ctrl-C, the watchdog in `crate::interrupt` runs the hook registered in
|
||||
// [`EphemeralContextGuard::new_with_context`] right before exiting — the
|
||||
// interrupt sequence skips all destructors, which would otherwise leak the
|
||||
// freshly bootstrapped chroot under /tmp together with its bind-mounted
|
||||
// /proc and any overlayfs mounts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Interrupt-time cleanup of an ephemeral chroot: unmount every host-side
|
||||
@@ -143,9 +28,9 @@ fn panic_message(panic: &(dyn Any + Send)) -> String {
|
||||
/// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go
|
||||
/// through the context manager, the ephemeral context's driver (whose
|
||||
/// `cleanup()` unmounts the tracked overlays) or the base context's command
|
||||
/// builder: the signal may arrive while the interrupted thread holds any of
|
||||
/// those mutexes, and re-locking them from the signal handler would deadlock.
|
||||
/// Instead it only reads /proc/mounts and spawns umount/rm directly.
|
||||
/// builder: interrupt-time hooks must be self-contained, and those
|
||||
/// machineries may be mid-mutation on the interrupted thread. Instead it
|
||||
/// only reads /proc/mounts and spawns umount/rm directly.
|
||||
///
|
||||
/// It also differs from `drop` in that it removes the chroot regardless of
|
||||
/// the build result: the build was aborted, and leaving a still-mounted
|
||||
@@ -175,31 +60,48 @@ fn sigint_cleanup_chroot(chroot_path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the chroot tree itself (tolerates a missing directory)
|
||||
let status = privileged_command("rm", is_root)
|
||||
.arg("-rf")
|
||||
.arg(chroot_path)
|
||||
.status();
|
||||
match status {
|
||||
Ok(status) if status.success() => {
|
||||
// Remove the chroot tree itself (tolerates a missing directory). A
|
||||
// child the Ctrl+C interrupted may still be finishing its writeout —
|
||||
// dpkg defers SIGINT until it reaches a safe state — so retry while rm
|
||||
// reports the tree non-empty instead of leaving it half-removed.
|
||||
const RETRIES: usize = 10;
|
||||
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
let mut last = None;
|
||||
for attempt in 0..=RETRIES {
|
||||
if attempt > 0 {
|
||||
std::thread::sleep(RETRY_DELAY);
|
||||
}
|
||||
last = Some(
|
||||
privileged_command("rm", is_root)
|
||||
.arg("-rf")
|
||||
.arg(chroot_path)
|
||||
.status(),
|
||||
);
|
||||
if matches!(&last, Some(Ok(status)) if status.success()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match last {
|
||||
Some(Ok(status)) if status.success() => {
|
||||
log::debug!(
|
||||
"Removed chroot {} during interrupt cleanup",
|
||||
chroot_path.display()
|
||||
);
|
||||
}
|
||||
Ok(status) => {
|
||||
Some(Ok(status)) => {
|
||||
log::error!(
|
||||
"Failed to remove chroot {} during interrupt cleanup \
|
||||
(rm exited with {status}); run `pkh prune`",
|
||||
chroot_path.display()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
Some(Err(e)) => {
|
||||
log::error!(
|
||||
"Failed to run rm for chroot {} during interrupt cleanup: {e}; run `pkh prune`",
|
||||
chroot_path.display()
|
||||
);
|
||||
}
|
||||
None => unreachable!("at least one rm attempt ran"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,15 +236,14 @@ impl EphemeralContextGuard {
|
||||
|
||||
// Register the interrupt-time cleanup hook before any heavy work: if
|
||||
// the user hits Ctrl-C during bootstrap or the build itself, the
|
||||
// SIGINT handler unmounts and removes the chroot through this hook
|
||||
// (see `sigint_cleanup_chroot`). This only works for a local base
|
||||
// context: the hook must be self-contained (stored path + direct
|
||||
// umount/rm subprocesses) and cannot go through `base_ctx`, whose
|
||||
// driver mutex may be held by the interrupted thread. For remote or
|
||||
// nested bases the chroot lives elsewhere, and leftovers stay
|
||||
// handled by `pkh prune` as before.
|
||||
// interrupt watchdog unmounts and removes the chroot through this
|
||||
// hook (see `sigint_cleanup_chroot`). This only works for a local
|
||||
// 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.
|
||||
let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) {
|
||||
Some(register_cleanup_hook(Box::new({
|
||||
Some(crate::interrupt::register_cleanup_hook(Box::new({
|
||||
let chroot_path = chroot_path.clone();
|
||||
move || sigint_cleanup_chroot(&chroot_path)
|
||||
})))
|
||||
@@ -359,10 +260,18 @@ impl EphemeralContextGuard {
|
||||
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), view)
|
||||
.await
|
||||
{
|
||||
// The guard (and its Drop) never materializes on this path, so
|
||||
// stop tracking the chroot for interrupt cleanup; as before, a
|
||||
// failed bootstrap leaves its partial directory in place.
|
||||
drop(cleanup_hook);
|
||||
// On a Ctrl+C the interrupt watchdog owns the tree: keep the
|
||||
// hook registered (forgetting the guard) so it removes the
|
||||
// partial directory, instead of the historical behavior of
|
||||
// leaving it in place. Without an interrupt this is a plain
|
||||
// bootstrap failure and the partial directory stays, as before.
|
||||
if crate::interrupt::interrupted()
|
||||
&& let Some(hook) = cleanup_hook
|
||||
{
|
||||
std::mem::forget(hook);
|
||||
} else {
|
||||
drop(cleanup_hook);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
@@ -437,6 +346,11 @@ impl EphemeralContextGuard {
|
||||
let poll_interval = 5; // Check every 5 seconds
|
||||
|
||||
while ctx.exists(&lockfile_path)? {
|
||||
// Stop waiting on a Ctrl+C: the interrupt watchdog removes the
|
||||
// (yet empty) chroot and exits without waiting for the poll
|
||||
if crate::interrupt::interrupted() {
|
||||
return Err("Interrupted while waiting for the chroot tarball".into());
|
||||
}
|
||||
if wait_time >= timeout {
|
||||
log::warn!(
|
||||
"Lockfile {} exists and has been present for more than {} seconds. \
|
||||
@@ -585,6 +499,11 @@ impl EphemeralContextGuard {
|
||||
// too expensive for multi-hundred-MB chroot tarballs)
|
||||
let mut count = 0usize;
|
||||
for entry in archive.entries()? {
|
||||
// Bail on a Ctrl+C before the interrupt watchdog's rm -rf races
|
||||
// this loop writing entries into the tree being removed
|
||||
if crate::interrupt::interrupted() {
|
||||
return Err("Interrupted while extracting the chroot".into());
|
||||
}
|
||||
let mut entry = entry?;
|
||||
entry.unpack_in(chroot_path)?;
|
||||
count += 1;
|
||||
@@ -702,6 +621,20 @@ impl EphemeralContextGuard {
|
||||
|
||||
impl Drop for EphemeralContextGuard {
|
||||
fn drop(&mut self) {
|
||||
// On Ctrl+C the interrupt watchdog owns the chroot teardown through
|
||||
// the registered hook: duplicating it here would race the hook's
|
||||
// umount/rm (mounts vanish under each other). Dropping this guard
|
||||
// would normally deregister the hook, so while the watchdog runs it
|
||||
// must be leaked instead to keep it registered (if it was already
|
||||
// drained, forgetting is a harmless no-op).
|
||||
if crate::interrupt::interrupted() {
|
||||
context::manager().set_current_ephemeral(self.previous_context.clone());
|
||||
if let Some(hook) = self.cleanup_hook.take() {
|
||||
std::mem::forget(hook);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Deregister the interrupt-time cleanup hook first: the normal
|
||||
// cleanup below takes care of the chroot, so the hook must not fire
|
||||
// afterwards. (If a SIGINT arrived mid-drop and the hook is already
|
||||
@@ -799,132 +732,8 @@ impl Drop for EphemeralContextGuard {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cleanup_registry_tests {
|
||||
mod chroot_cleanup_tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// Serializes these tests: they drain the process-global registry, and
|
||||
/// unrelated tests (e.g. live end-to-end builds) may hold registrations
|
||||
/// concurrently that must be neither run nor lost. Poison-proof: a test
|
||||
/// failing while holding the lock must not cascade into the others.
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn test_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Drain the registry and take out only the hooks with the given ids,
|
||||
/// putting everything else back so unrelated registrations (e.g. hooks of
|
||||
/// live end-to-end builds running concurrently) stay pending
|
||||
fn take_hooks(ids: &[u64]) -> Vec<CleanupHook> {
|
||||
let drained = drain_cleanup_hooks();
|
||||
let mut mine = Vec::new();
|
||||
let mut others = Vec::new();
|
||||
for hook in drained {
|
||||
if ids.contains(&hook.id) {
|
||||
mine.push(hook);
|
||||
} else {
|
||||
others.push(hook);
|
||||
}
|
||||
}
|
||||
CLEANUP_HOOKS.lock().unwrap().extend(others);
|
||||
mine
|
||||
}
|
||||
|
||||
/// Register a hook that counts its invocations
|
||||
fn counting_hook() -> (CleanupHookGuard, Arc<AtomicUsize>) {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let seen = counter.clone();
|
||||
let guard = register_cleanup_hook(Box::new(move || {
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
(guard, counter)
|
||||
}
|
||||
|
||||
/// Hooks run in registration order, and draining means each hook runs
|
||||
/// exactly once even across repeated cleanup passes.
|
||||
#[test]
|
||||
fn hooks_run_once_in_registration_order() {
|
||||
let _serial = test_lock();
|
||||
|
||||
let log = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut guards = Vec::new();
|
||||
let mut ids = Vec::new();
|
||||
for name in ["hook-a", "hook-b", "hook-c"] {
|
||||
let log = log.clone();
|
||||
// The returned guard must stay alive: dropping it deregisters
|
||||
let guard = register_cleanup_hook(Box::new(move || log.lock().unwrap().push(name)));
|
||||
ids.push(guard.id());
|
||||
guards.push(guard);
|
||||
}
|
||||
|
||||
// Only our own hooks are extracted; they run in registration order
|
||||
let mine = take_hooks(&ids);
|
||||
assert_eq!(mine.len(), ids.len());
|
||||
run_drained_hooks(mine);
|
||||
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
|
||||
|
||||
// Draining removed them: a second pass runs nothing again
|
||||
assert!(take_hooks(&ids).is_empty());
|
||||
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
|
||||
|
||||
drop(guards);
|
||||
}
|
||||
|
||||
/// A panicking hook is contained by the runner: it neither aborts the
|
||||
/// process nor skips the hooks registered around it.
|
||||
#[test]
|
||||
fn panicking_hook_does_not_skip_the_others() {
|
||||
let _serial = test_lock();
|
||||
// The hook below panics on purpose: do not record it as a test
|
||||
// failure in the end-of-run matrix
|
||||
let _quiet = crate::test_support::suppress_failure_recording();
|
||||
|
||||
let (before, ran_before) = counting_hook();
|
||||
let boom = register_cleanup_hook(Box::new(|| panic!("cleanup exploded")));
|
||||
let (after, ran_after) = counting_hook();
|
||||
|
||||
let ids = [before.id(), boom.id(), after.id()];
|
||||
run_drained_hooks(take_hooks(&ids));
|
||||
|
||||
assert_eq!(ran_before.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(ran_after.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
/// Explicit deregistration removes the hook: it is no longer drained and
|
||||
/// never runs; a second deregistration reports it as already gone.
|
||||
#[test]
|
||||
fn deregistered_hook_never_runs() {
|
||||
let _serial = test_lock();
|
||||
|
||||
let (mut guard, ran) = counting_hook();
|
||||
|
||||
assert!(guard.deregister());
|
||||
assert!(!guard.deregister());
|
||||
|
||||
assert!(take_hooks(&[guard.id()]).is_empty());
|
||||
assert_eq!(ran.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
/// Dropping the registration guard deregisters the hook implicitly.
|
||||
#[test]
|
||||
fn dropping_the_guard_deregisters_the_hook() {
|
||||
let _serial = test_lock();
|
||||
|
||||
let id;
|
||||
let ran;
|
||||
{
|
||||
let (guard, counter) = counting_hook();
|
||||
id = guard.id();
|
||||
ran = counter;
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
assert!(take_hooks(&[id]).is_empty());
|
||||
assert_eq!(ran.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
/// /proc/mounts path fields use octal escapes for whitespace and
|
||||
/// backslashes; anything else must be kept verbatim.
|
||||
|
||||
+14
-6
@@ -34,6 +34,7 @@ pub async fn build(
|
||||
series: &str,
|
||||
pocket: Option<&str>,
|
||||
build_root: &str,
|
||||
package_dir: &Path,
|
||||
cross: bool,
|
||||
ppa: &[String],
|
||||
inject_packages: &[String],
|
||||
@@ -231,10 +232,8 @@ pub async fn build(
|
||||
return Err("Could not install essential packages for the build".into());
|
||||
}
|
||||
|
||||
// Find the actual package directory
|
||||
// Find the actual package directory
|
||||
let package_dir =
|
||||
crate::deb::find_package_directory(Path::new(build_root), package, version, series, &ctx)?;
|
||||
// The package directory was resolved by the caller (the staged copy of
|
||||
// the tree the user pointed at, or the name-pattern search fallback)
|
||||
let package_dir_str = package_dir
|
||||
.to_str()
|
||||
.ok_or("Invalid package directory path")?;
|
||||
@@ -725,8 +724,17 @@ fn install_build_dependencies(
|
||||
let status = cap(&mut cmd, sink).status()?;
|
||||
if !status.success() {
|
||||
view.suspend();
|
||||
if let Err(e) =
|
||||
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())
|
||||
// Diagnosing a dependency failure the user interrupted themselves
|
||||
// is wasted work
|
||||
if !crate::interrupt::interrupted()
|
||||
&& let Err(e) = dose3_explain_dependencies(
|
||||
package,
|
||||
version,
|
||||
arch,
|
||||
build_root,
|
||||
cross,
|
||||
ctx.clone(),
|
||||
)
|
||||
{
|
||||
log::debug!("dose-builddebcheck diagnosis failed: {e}");
|
||||
}
|
||||
|
||||
+224
-5
@@ -269,6 +269,19 @@ async fn build_binary_package_impl(
|
||||
.ok_or("Cannot find parent directory name")?;
|
||||
let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap());
|
||||
|
||||
// Resolve the package directory inside the staging area. The tree
|
||||
// the caller pointed at is authoritative (its changelog defined the
|
||||
// package/version/series above), so its staged copy wins; the
|
||||
// name-pattern search only runs as a fallback.
|
||||
let package_dir = resolve_package_directory(
|
||||
Path::new(&build_root),
|
||||
cwd,
|
||||
&package,
|
||||
&version,
|
||||
series,
|
||||
&build_ctx,
|
||||
)?;
|
||||
|
||||
// Run the build using target build mode. It returns the exact set of
|
||||
// artifacts produced by this build (binary packages registered in
|
||||
// debian/files plus the generated .buildinfo/.changes), as paths
|
||||
@@ -282,6 +295,7 @@ async fn build_binary_package_impl(
|
||||
series,
|
||||
pocket.as_deref(),
|
||||
&build_root,
|
||||
&package_dir,
|
||||
cross,
|
||||
ppa,
|
||||
inject,
|
||||
@@ -329,6 +343,38 @@ async fn build_binary_package_impl(
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve the package directory for a build inside the staged build root.
|
||||
///
|
||||
/// The tree the caller pointed at is authoritative: `cwd`'s changelog
|
||||
/// already defined the package, version and series for this build, so its
|
||||
/// staged copy is used outright when it carries a `debian/` tree. The
|
||||
/// name-pattern search ([`find_package_directory`], including the quirks
|
||||
/// overrides) only runs when that copy cannot be resolved — a default `.`
|
||||
/// cwd has no basename, and the pointed-at tree may live outside the staged
|
||||
/// parent. Embedded callers are the motivation: their working directory
|
||||
/// names (`tree`, `checkout`, ...) match none of the search patterns.
|
||||
pub(crate) fn resolve_package_directory(
|
||||
build_root: &Path,
|
||||
cwd: &Path,
|
||||
package: &str,
|
||||
version: &str,
|
||||
series: &str,
|
||||
ctx: &context::Context,
|
||||
) -> Result<PathBuf, Box<dyn Error>> {
|
||||
if let Some(tree_name) = cwd.file_name() {
|
||||
let staged_tree = build_root.join(tree_name);
|
||||
if ctx.is_dir(&staged_tree)? && ctx.exists(&staged_tree.join("debian"))? {
|
||||
log::debug!(
|
||||
"Using the staged copy of {} at {}",
|
||||
cwd.display(),
|
||||
staged_tree.display()
|
||||
);
|
||||
return Ok(staged_tree);
|
||||
}
|
||||
}
|
||||
find_package_directory(build_root, package, version, series, ctx)
|
||||
}
|
||||
|
||||
/// Find the current package directory by trying both patterns:
|
||||
/// - package/package
|
||||
/// - package/package-origversion
|
||||
@@ -413,11 +459,13 @@ pub(crate) fn find_package_directory(
|
||||
let entries = ctx.list_files(package_parent)?;
|
||||
let mut found_dirs = Vec::new();
|
||||
for entry in entries {
|
||||
if entry.is_dir() {
|
||||
if let Some(file_name) = entry.file_name() {
|
||||
found_dirs.push(file_name.to_string_lossy().into_owned());
|
||||
}
|
||||
log::debug!(" - {}", entry.display());
|
||||
// list_files yields context-relative paths (e.g. rooted inside
|
||||
// the chroot for an unshare context): classify through the
|
||||
// context, a host-side stat would miss every entry.
|
||||
let is_dir = ctx.is_dir(&entry)?;
|
||||
log::debug!(" - {}", entry.display());
|
||||
if is_dir && let Some(file_name) = entry.file_name() {
|
||||
found_dirs.push(file_name.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +549,101 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An unshare context mapped over `chroot_root`, parented on a local
|
||||
/// context like the ephemeral build contexts are: exists/list_files/
|
||||
/// is_dir answer through the path mapping, no namespace privileges
|
||||
/// needed.
|
||||
fn unshare_test_context(chroot_root: &Path) -> Context {
|
||||
let base = Context::new(crate::context::ContextConfig::Local).unwrap();
|
||||
Context::with_parent(
|
||||
crate::context::ContextConfig::Unshare {
|
||||
path: chroot_root.to_string_lossy().to_string(),
|
||||
parent: None,
|
||||
},
|
||||
Arc::new(base),
|
||||
)
|
||||
}
|
||||
|
||||
/// The staging-area listing must classify entries through the context:
|
||||
/// an unshare context returns build-root-relative paths that a host-side
|
||||
/// stat never sees (they live under the chroot root on the host), which
|
||||
/// used to silently empty the 'Found directories' list of the search
|
||||
/// failure message — and with it every hint about the actual layout.
|
||||
#[test]
|
||||
fn find_package_directory_lists_staged_directories_through_the_context() {
|
||||
let chroot = tempfile::tempdir().unwrap();
|
||||
// Staged parent holding a single tree whose name matches none of
|
||||
// the search patterns (the embedded-caller layout: <job>/tree)
|
||||
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
||||
std::fs::create_dir_all(staged_parent.join("tree/debian")).unwrap();
|
||||
|
||||
let ctx = unshare_test_context(chroot.path());
|
||||
let err = find_package_directory(
|
||||
Path::new("/tmp/pkh-build-1/j-42"),
|
||||
"bc",
|
||||
"1.07.1-1ubuntu1",
|
||||
"questing",
|
||||
&ctx,
|
||||
)
|
||||
.expect_err("no candidate matches a tree named 'tree'");
|
||||
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("Found directories: tree"),
|
||||
"error should list the staged directories through the context: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicit cwd must resolve to its staged copy even when its name
|
||||
/// matches none of the search patterns: the pointed-at tree is what the
|
||||
/// parsed changelog came from.
|
||||
#[test]
|
||||
fn resolve_package_directory_prefers_the_pointed_tree() {
|
||||
let chroot = tempfile::tempdir().unwrap();
|
||||
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
||||
std::fs::create_dir_all(staged_parent.join("tree/debian/source")).unwrap();
|
||||
|
||||
let ctx = unshare_test_context(chroot.path());
|
||||
let resolved = resolve_package_directory(
|
||||
Path::new("/tmp/pkh-build-1/j-42"),
|
||||
Path::new("/work/jobs/j-42/tree"),
|
||||
"bc",
|
||||
"1.07.1-1ubuntu1",
|
||||
"questing",
|
||||
&ctx,
|
||||
)
|
||||
.expect("the staged copy of the pointed-at tree must resolve");
|
||||
|
||||
assert_eq!(resolved, PathBuf::from("/tmp/pkh-build-1/j-42/tree"));
|
||||
}
|
||||
|
||||
/// When the pointed-at tree is not in the staging area under its own
|
||||
/// name, resolution falls back to the name-pattern search.
|
||||
#[test]
|
||||
fn resolve_package_directory_falls_back_to_the_name_search() {
|
||||
let chroot = tempfile::tempdir().unwrap();
|
||||
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
||||
// Staged copy of a pulled tree: <pkg>/<pkg>-<origversion>
|
||||
std::fs::create_dir_all(staged_parent.join("bc/bc-1.07.1/debian")).unwrap();
|
||||
|
||||
let ctx = unshare_test_context(chroot.path());
|
||||
let resolved = resolve_package_directory(
|
||||
Path::new("/tmp/pkh-build-1/j-42"),
|
||||
// A tree never staged under that name
|
||||
Path::new("/work/other/checkout"),
|
||||
"bc",
|
||||
"1.07.1-1ubuntu1",
|
||||
"questing",
|
||||
&ctx,
|
||||
)
|
||||
.expect("the pulled-tree layout must resolve via the name search");
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
PathBuf::from("/tmp/pkh-build-1/j-42/bc/bc-1.07.1")
|
||||
);
|
||||
}
|
||||
|
||||
async fn test_build_end_to_end(
|
||||
package: &str,
|
||||
series: &str,
|
||||
@@ -837,4 +980,80 @@ mod tests {
|
||||
"error should name the unsatisfied dependency: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An embedded-caller layout — the tree checked out at <job>/tree, a
|
||||
/// name matching none of the search patterns — must build: the staged
|
||||
/// copy of the tree the caller pointed at is resolved directly instead
|
||||
/// of being re-derived from package/version names (which used to fail
|
||||
/// with 'Could not find package directory').
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
async fn test_deb_builds_a_tree_named_directory_end_to_end() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let pkg_dir = temp_dir.path().join("j-42/tree");
|
||||
std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/changelog"),
|
||||
"pkh-treetest (1.0) noble; urgency=medium\n\n \
|
||||
* Synthetic package built from a directory named 'tree'.\n\n \
|
||||
-- pkh tests <pkh@example.com> Tue, 15 Sep 2026 08:00:00 +0000\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/control"),
|
||||
"Source: pkh-treetest\n\
|
||||
Section: devel\n\
|
||||
Priority: optional\n\
|
||||
Maintainer: pkh tests <pkh@example.com>\n\
|
||||
Standards-Version: 4.7.4\n\
|
||||
Build-Depends: debhelper-compat (= 13)\n\
|
||||
Architecture: any\n\
|
||||
\n\
|
||||
Package: pkh-treetest\n\
|
||||
Architecture: any\n\
|
||||
Depends: ${misc:Depends}, ${shlibs:Depends}\n\
|
||||
Description: Package-directory resolution regression package\n \
|
||||
Its tree lives in a directory whose name matches none of the\n \
|
||||
package-directory search patterns.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/rules"),
|
||||
"#!/usr/bin/make -f\n%:\n\tdh $@\n",
|
||||
)
|
||||
.unwrap();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let rules = pkg_dir.join("debian/rules");
|
||||
let mut perms = std::fs::metadata(&rules).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&rules, perms).unwrap();
|
||||
|
||||
std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap();
|
||||
|
||||
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||
|
||||
crate::deb::build_binary_package(DebBuildOptions {
|
||||
series: Some("noble".to_string()),
|
||||
cwd: Some(pkg_dir),
|
||||
ctx: Some(ctx),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("a tree named 'tree' must build");
|
||||
|
||||
let deb_files: Vec<String> = std::fs::read_dir(temp_dir.path().join("j-42"))
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
deb_files
|
||||
.iter()
|
||||
.any(|f| f.starts_with("pkh-treetest_1.0_") && f.ends_with(".deb")),
|
||||
".deb not produced for the 'tree'-named directory, got: {deb_files:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Passive interrupt state shared between the CLI and the library.
|
||||
//!
|
||||
//! Everything active about Ctrl+C lives in the CLI (`main.rs`): it installs
|
||||
//! the SIGINT handler, wakes a watchdog thread, prints the interrupt notice
|
||||
//! and exits with the conventional status 130. This module only holds the
|
||||
//! state the library's own types need:
|
||||
//!
|
||||
//! - the interrupted flag ([`mark_interrupted`] / [`interrupted`]), read by
|
||||
//! flows so they stand down while the watchdog tears everything down;
|
||||
//! - the cleanup hook registry ([`register_cleanup_hook`]) for resources
|
||||
//! that must not outlive the process (e.g. the ephemeral build chroot,
|
||||
//! see [`crate::deb::ephemeral`]), drained and run by the CLI watchdog
|
||||
//! right before exiting ([`run_cleanup_hooks`]);
|
||||
//! - the reporter slot ([`set_reporter`]): the live build view registers
|
||||
//! how to clear the terminal (and where the full log lives); the CLI
|
||||
//! runs it as the first step of the shutdown.
|
||||
//!
|
||||
//! Nothing here installs signal handlers, prints or exits: a library
|
||||
//! consumer embedding these types keeps its own signal disposition.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
/// How the live view reports an interrupt: it clears the terminal and
|
||||
/// returns the log-file hint to print below the notice, if any
|
||||
pub type Reporter = Box<dyn FnOnce() -> Option<String> + Send>;
|
||||
|
||||
/// A boxed, send-safe cleanup hook body
|
||||
type CleanupFn = Box<dyn Fn() + Send>;
|
||||
|
||||
/// The reporter run before the cleanup hooks; taken out when it runs
|
||||
static REPORTER: Mutex<Option<Reporter>> = Mutex::new(None);
|
||||
|
||||
/// Whether a Ctrl+C has been intercepted since the CLI installed the
|
||||
/// handler
|
||||
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Registry of cleanup hooks waiting to run at interrupt time
|
||||
static CLEANUP_HOOKS: Mutex<Vec<CleanupHook>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Source of the registry ids used to deregister a specific hook
|
||||
static NEXT_CLEANUP_HOOK_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// A pending cleanup hook together with its registry id
|
||||
struct CleanupHook {
|
||||
id: u64,
|
||||
f: CleanupFn,
|
||||
}
|
||||
|
||||
/// Record that a Ctrl+C has been intercepted; called by the CLI signal
|
||||
/// handler
|
||||
pub fn mark_interrupted() {
|
||||
INTERRUPTED.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Whether a Ctrl+C has been intercepted; flows use this to stay quiet and
|
||||
/// to leave the cleanup to the CLI watchdog
|
||||
pub fn interrupted() -> bool {
|
||||
INTERRUPTED.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Register how the live view reports an interrupt: the CLI watchdog runs
|
||||
/// it as the first step of the shutdown, before the cleanup hooks. At most
|
||||
/// one reporter runs per process: a later call replaces the one set before.
|
||||
/// Without any reporter the watchdog only prints the plain notice.
|
||||
pub fn set_reporter(report: Reporter) {
|
||||
*REPORTER.lock().unwrap_or_else(PoisonError::into_inner) = Some(report);
|
||||
}
|
||||
|
||||
/// Take the registered reporter out of the slot; `None` when no live view
|
||||
/// registered one (`--verbose`, piped output)
|
||||
pub fn take_reporter() -> Option<Reporter> {
|
||||
REPORTER
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.take()
|
||||
}
|
||||
|
||||
/// Register a hook to be run when the process is interrupted (after the
|
||||
/// reporter), returning a guard whose drop deregisters the hook again.
|
||||
///
|
||||
/// Hooks must be self-contained — stored paths plus direct subprocesses —
|
||||
/// and must never block indefinitely: they run in the watchdog while the
|
||||
/// interrupted flow is still unwinding, and a second Ctrl+C during cleanup
|
||||
/// is a no-op.
|
||||
pub fn register_cleanup_hook(f: CleanupFn) -> CleanupHookGuard {
|
||||
let id = NEXT_CLEANUP_HOOK_ID.fetch_add(1, Ordering::Relaxed);
|
||||
CLEANUP_HOOKS
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.push(CleanupHook { id, f });
|
||||
CleanupHookGuard(id)
|
||||
}
|
||||
|
||||
/// RAII handle to a registered cleanup hook: dropping it (or an explicit
|
||||
/// [`CleanupHookGuard::deregister`]) removes the hook from the registry so
|
||||
/// the interrupt path can no longer run it
|
||||
pub struct CleanupHookGuard(u64);
|
||||
|
||||
impl CleanupHookGuard {
|
||||
/// Registry id of the hook (used to filter the registry in tests)
|
||||
#[cfg(test)]
|
||||
fn id(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Remove the hook from the registry; returns whether it was still
|
||||
/// pending
|
||||
pub fn deregister(&mut self) -> bool {
|
||||
deregister_cleanup_hook(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CleanupHookGuard {
|
||||
fn drop(&mut self) {
|
||||
deregister_cleanup_hook(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a hook from the registry; returns whether it was still pending
|
||||
fn deregister_cleanup_hook(id: u64) -> bool {
|
||||
let mut hooks = CLEANUP_HOOKS.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let len_before = hooks.len();
|
||||
hooks.retain(|hook| hook.id != id);
|
||||
hooks.len() != len_before
|
||||
}
|
||||
|
||||
/// Drain and run every registered cleanup hook exactly once.
|
||||
///
|
||||
/// Called by the CLI watchdog right before the process exits. Draining uses
|
||||
/// `try_lock` with a bounded retry instead of a blocking lock as a hard
|
||||
/// upper bound on interrupt latency: the sequence must never hang waiting
|
||||
/// for a lock, however unlikely a stalled holder is. Timing out therefore
|
||||
/// skips cleanup (leaking) rather than hanging.
|
||||
pub fn run_cleanup_hooks() {
|
||||
run_drained_hooks(drain_cleanup_hooks());
|
||||
}
|
||||
|
||||
/// Take every pending hook out of the registry, waiting at most ~1s for the
|
||||
/// registry lock (see [`run_cleanup_hooks`] for why this must not block
|
||||
/// forever)
|
||||
fn drain_cleanup_hooks() -> Vec<CleanupHook> {
|
||||
const RETRIES: usize = 200;
|
||||
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(5);
|
||||
|
||||
for _ in 0..RETRIES {
|
||||
if let Ok(mut hooks) = CLEANUP_HOOKS.try_lock() {
|
||||
return std::mem::take(&mut *hooks);
|
||||
}
|
||||
std::thread::sleep(RETRY_DELAY);
|
||||
}
|
||||
log::error!("Timed out waiting for the cleanup hook registry; skipping interrupt cleanup");
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Run drained hooks one by one, isolating panics so that one failing hook
|
||||
/// cannot skip the remaining ones
|
||||
fn run_drained_hooks(hooks: Vec<CleanupHook>) {
|
||||
for CleanupHook { id, f } in hooks {
|
||||
// Hooks are arbitrary user code; assert unwind safety so they can be
|
||||
// run inside a catching context
|
||||
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
||||
log::error!("Cleanup hook {id} panicked: {}", panic_message(&panic));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort message extraction from a panic payload
|
||||
fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
|
||||
if let Some(s) = panic.downcast_ref::<&str>() {
|
||||
(*s).to_string()
|
||||
} else if let Some(s) = panic.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"non-string panic payload".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// Serializes these tests: they drain the process-global registry, and
|
||||
/// unrelated tests may hold registrations concurrently that must be
|
||||
/// neither run nor lost. Poison-proof: a test failing while holding the
|
||||
/// lock must not cascade into the others.
|
||||
static TEST_LOCK: StdMutex<()> = StdMutex::new(());
|
||||
|
||||
fn test_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
TEST_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Drain the registry and take out only the hooks with the given ids,
|
||||
/// putting everything else back so unrelated registrations stay pending
|
||||
fn take_hooks(ids: &[u64]) -> Vec<CleanupHook> {
|
||||
let drained = drain_cleanup_hooks();
|
||||
let mut mine = Vec::new();
|
||||
let mut others = Vec::new();
|
||||
for hook in drained {
|
||||
if ids.contains(&hook.id) {
|
||||
mine.push(hook);
|
||||
} else {
|
||||
others.push(hook);
|
||||
}
|
||||
}
|
||||
CLEANUP_HOOKS
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.extend(others);
|
||||
mine
|
||||
}
|
||||
|
||||
/// Register a hook that counts its invocations
|
||||
fn counting_hook() -> (CleanupHookGuard, Arc<AtomicUsize>) {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let seen = counter.clone();
|
||||
let guard = register_cleanup_hook(Box::new(move || {
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
(guard, counter)
|
||||
}
|
||||
|
||||
/// Hooks run in registration order, and draining means each hook runs
|
||||
/// exactly once even across repeated cleanup passes.
|
||||
#[test]
|
||||
fn hooks_run_once_in_registration_order() {
|
||||
let _serial = test_lock();
|
||||
|
||||
let log = Arc::new(StdMutex::new(Vec::new()));
|
||||
let mut guards = Vec::new();
|
||||
let mut ids = Vec::new();
|
||||
for name in ["hook-a", "hook-b", "hook-c"] {
|
||||
let log = log.clone();
|
||||
// The returned guard must stay alive: dropping it deregisters
|
||||
let guard = register_cleanup_hook(Box::new(move || {
|
||||
log.lock().unwrap().push(name);
|
||||
}));
|
||||
ids.push(guard.id());
|
||||
guards.push(guard);
|
||||
}
|
||||
|
||||
// Only our own hooks are extracted; they run in registration order
|
||||
let mine = take_hooks(&ids);
|
||||
assert_eq!(mine.len(), ids.len());
|
||||
run_drained_hooks(mine);
|
||||
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
|
||||
|
||||
// Draining removed them: a second pass runs nothing again
|
||||
assert!(take_hooks(&ids).is_empty());
|
||||
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
|
||||
|
||||
drop(guards);
|
||||
}
|
||||
|
||||
/// A panicking hook is contained by the runner: it neither aborts the
|
||||
/// process nor skips the hooks registered around it.
|
||||
#[test]
|
||||
fn panicking_hook_does_not_skip_the_others() {
|
||||
let _serial = test_lock();
|
||||
// The hook below panics on purpose: do not record it as a test
|
||||
// failure in the end-of-run matrix
|
||||
let _quiet = crate::test_support::suppress_failure_recording();
|
||||
|
||||
let (before, ran_before) = counting_hook();
|
||||
let boom = register_cleanup_hook(Box::new(|| panic!("cleanup exploded")));
|
||||
let (after, ran_after) = counting_hook();
|
||||
|
||||
let ids = [before.id(), boom.id(), after.id()];
|
||||
run_drained_hooks(take_hooks(&ids));
|
||||
|
||||
assert_eq!(ran_before.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(ran_after.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
/// Explicit deregistration removes the hook: it is no longer drained and
|
||||
/// never runs; a second deregistration reports it as already gone.
|
||||
#[test]
|
||||
fn deregistered_hook_never_runs() {
|
||||
let _serial = test_lock();
|
||||
|
||||
let (mut guard, ran) = counting_hook();
|
||||
|
||||
assert!(guard.deregister());
|
||||
assert!(!guard.deregister());
|
||||
|
||||
assert!(take_hooks(&[guard.id()]).is_empty());
|
||||
assert_eq!(ran.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
/// Dropping the registration guard deregisters the hook implicitly.
|
||||
#[test]
|
||||
fn dropping_the_guard_deregisters_the_hook() {
|
||||
let _serial = test_lock();
|
||||
|
||||
let id;
|
||||
let ran;
|
||||
{
|
||||
let (guard, counter) = counting_hook();
|
||||
id = guard.id();
|
||||
ran = counter;
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
assert!(take_hooks(&[id]).is_empty());
|
||||
assert_eq!(ran.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ pub mod deb;
|
||||
pub mod debian;
|
||||
/// Obtain general information about distribution, series, etc
|
||||
pub mod distro_info;
|
||||
/// Passive interrupt state: the interrupted flag, the cleanup hook registry
|
||||
/// and the live view's reporter slot (the CLI owns the signal handling)
|
||||
pub mod interrupt;
|
||||
/// Launchpad integration: PPA upload targets and account discovery
|
||||
pub mod launchpad;
|
||||
/// Lint a source tree: lintian wrapper for full parity plus pkh-native checks (`pkh lint`)
|
||||
|
||||
+226
@@ -20,6 +20,155 @@ fn current_dir_or_exit() -> std::path::PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 =
|
||||
@@ -513,6 +662,7 @@ fn main() {
|
||||
}
|
||||
Some(("build", sub_matches)) => {
|
||||
let cwd = current_dir_or_exit();
|
||||
interrupt::install();
|
||||
let verbose = sub_matches
|
||||
.get_one::<bool>("verbose")
|
||||
.copied()
|
||||
@@ -563,6 +713,11 @@ fn main() {
|
||||
}
|
||||
}
|
||||
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) =
|
||||
@@ -584,6 +739,7 @@ fn main() {
|
||||
}
|
||||
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")
|
||||
@@ -613,12 +769,22 @@ fn main() {
|
||||
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();
|
||||
// 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();
|
||||
@@ -685,6 +851,13 @@ fn main() {
|
||||
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);
|
||||
}
|
||||
@@ -816,3 +989,56 @@ fn main() {
|
||||
_ => 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +445,10 @@ mod imp {
|
||||
self.inner.exists(path)
|
||||
}
|
||||
|
||||
fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||
self.inner.is_dir(path)
|
||||
}
|
||||
|
||||
fn cleanup(&self) -> io::Result<()> {
|
||||
self.inner.cleanup()
|
||||
}
|
||||
|
||||
+141
-68
@@ -11,14 +11,14 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType};
|
||||
use crossterm::style::Stylize;
|
||||
use directories::ProjectDirs;
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use crate::context::{LineSink, Stream};
|
||||
@@ -84,6 +84,17 @@ impl DebUi {
|
||||
let enabled = is_stdout_tty();
|
||||
|
||||
let top = if enabled {
|
||||
// The tty renders a ^C keypress as the two visible characters
|
||||
// "^C"; on a terminal where the cursor sits near the right edge
|
||||
// that wraps to the next row, and the erase at teardown —
|
||||
// anchored to where indicatif last drew — ends up one row off,
|
||||
// leaving the first widget line on screen. Rendering control
|
||||
// characters raw instead (ECHOCTL off) makes the echo an
|
||||
// invisible byte that moves nothing; ECHO itself stays on, so
|
||||
// terminals showing a padlock while input is hidden are not
|
||||
// triggered. Restored by `suspend_shared`.
|
||||
suppress_control_char_echo();
|
||||
multi.set_draw_target(ProgressDrawTarget::stderr());
|
||||
let pb = multi.add(ProgressBar::new(0));
|
||||
pb.enable_steady_tick(Duration::from_millis(80));
|
||||
pb.set_style(spinner_style());
|
||||
@@ -110,7 +121,7 @@ impl DebUi {
|
||||
bar_total: 0,
|
||||
}),
|
||||
tee: Mutex::new(None),
|
||||
log_path: Mutex::new(log_path.clone()),
|
||||
log_path: Mutex::new(log_path),
|
||||
timestamp,
|
||||
enabled,
|
||||
suspended: AtomicBool::new(false),
|
||||
@@ -118,8 +129,13 @@ impl DebUi {
|
||||
}),
|
||||
};
|
||||
|
||||
// Ctrl+C: the signal wiring lives in the CLI; this view only
|
||||
// registers with `crate::interrupt` how to clear itself and where
|
||||
// the full log lives. The log path is read from the shared state at
|
||||
// interrupt time, so the rename in `open_log` stays visible to the
|
||||
// reporter.
|
||||
if ui.shared.enabled {
|
||||
install_sigint_hook(&log_path);
|
||||
set_interrupt_reporter(ui.shared.clone());
|
||||
}
|
||||
|
||||
ui
|
||||
@@ -128,7 +144,7 @@ impl DebUi {
|
||||
/// Identify the binary package being built; names the log file and the
|
||||
/// status bar
|
||||
pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) {
|
||||
if self.shared.enabled {
|
||||
if self.active() {
|
||||
self.shared.top.set_prefix(format!(
|
||||
"Building {package} ({version}) for {series}/{arch}"
|
||||
));
|
||||
@@ -149,7 +165,6 @@ impl DebUi {
|
||||
};
|
||||
let _ = fs::rename(&old_path, &log_path);
|
||||
*self.shared.log_path.lock().unwrap() = log_path.clone();
|
||||
update_sigint_log_path(&log_path);
|
||||
|
||||
if let Some(dir) = log_path.parent() {
|
||||
let _ = fs::create_dir_all(dir);
|
||||
@@ -182,7 +197,7 @@ impl DebUi {
|
||||
st.bar_total = 0;
|
||||
st.last_draw = Instant::now();
|
||||
}
|
||||
if self.shared.enabled {
|
||||
if self.active() {
|
||||
self.shared.top.set_style(spinner_style());
|
||||
self.shared.top.set_message(label.to_string());
|
||||
drop_pane(&self.shared);
|
||||
@@ -197,19 +212,8 @@ impl DebUi {
|
||||
/// Release the widget from the terminal (e.g. before printing
|
||||
/// passthrough diagnostics or letting child cleanup commands write to
|
||||
/// the terminal); idempotent
|
||||
///
|
||||
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
|
||||
/// right after the clear, leaving stale copies of the widget on screen.
|
||||
fn suspend(&self) {
|
||||
if !self.shared.enabled {
|
||||
return;
|
||||
}
|
||||
if self.shared.suspended.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
self.shared.top.disable_steady_tick();
|
||||
drop_pane(&self.shared);
|
||||
self.shared.top.finish_and_clear();
|
||||
suspend_shared(&self.shared);
|
||||
}
|
||||
|
||||
/// Success outcome body: clear the widget and print the artifacts,
|
||||
@@ -226,9 +230,17 @@ impl DebUi {
|
||||
|
||||
/// Failure outcome body: clear the widget and print a summary (recent
|
||||
/// captured errors and the path to the full log)
|
||||
///
|
||||
/// On a Ctrl+C the interrupt watchdog owns the reporting — its captured
|
||||
/// errors are just the killed children's death throes, and the watchdog
|
||||
/// already points at the full log — so this prints nothing.
|
||||
fn failure_summary(&self) {
|
||||
self.suspend();
|
||||
|
||||
if crate::interrupt::interrupted() {
|
||||
return;
|
||||
}
|
||||
|
||||
let st = self.shared.state.lock().unwrap();
|
||||
if self.shared.enabled && !st.errors.is_empty() {
|
||||
eprintln!("Last captured errors:");
|
||||
@@ -256,7 +268,7 @@ impl DebUi {
|
||||
/// widget.
|
||||
impl crate::report::BuildView for DebUi {
|
||||
fn target(&self, target: BuildTarget<'_>) {
|
||||
if self.shared.enabled {
|
||||
if self.active() {
|
||||
self.shared.top.set_prefix(target.display.clone());
|
||||
}
|
||||
if target.tee_log {
|
||||
@@ -529,61 +541,90 @@ fn default_log_path(timestamp: &str) -> PathBuf {
|
||||
dir.join(format!("pkh-{timestamp}.log"))
|
||||
}
|
||||
|
||||
static SIGINT_LOG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
static SIGINT_INSTALLED: AtomicBool = AtomicBool::new(false);
|
||||
/// Register the interrupt reporter: release the widget from the terminal
|
||||
/// and return the log-file hint to print below the notice
|
||||
///
|
||||
/// The CLI watchdog runs this as the first step of the interrupt shutdown,
|
||||
/// so the widget disappears the moment Ctrl+C is hit. Registered only for
|
||||
/// enabled views: in `--verbose` mode or with piped output there is no
|
||||
/// widget and nothing to report.
|
||||
fn set_interrupt_reporter(shared: Arc<Shared>) {
|
||||
crate::interrupt::set_reporter(Box::new(move || interrupt_report(&shared)));
|
||||
}
|
||||
|
||||
/// Install a best-effort Ctrl+C handler clearing the widget and pointing at
|
||||
/// the log file before exiting
|
||||
fn install_sigint_hook(log_path: &Path) {
|
||||
update_sigint_log_path(log_path);
|
||||
if SIGINT_INSTALLED.swap(true, Ordering::SeqCst) {
|
||||
/// [`suspend_shared`] plus the log-file hint, in teardown order
|
||||
///
|
||||
/// Testable end to end: the reporter closure is private to the interrupt
|
||||
/// watchdog, but the drawing behavior is not.
|
||||
fn interrupt_report(shared: &Shared) -> Option<String> {
|
||||
suspend_shared(shared);
|
||||
let log_path = shared.log_path.lock().unwrap().clone();
|
||||
if log_path.exists() {
|
||||
Some(format!("Full log: {}", log_path.display()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// [`DebUi::suspend`] body, shared with the interrupt reporter
|
||||
///
|
||||
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
|
||||
/// right after the clear, leaving stale copies of the widget on screen.
|
||||
/// The tty echo suppressed at view start is restored here, before the
|
||||
/// erases: an echo from a keypress landing mid-teardown could not shift the
|
||||
/// cursor anymore. The draw target is finally killed: the interrupted flow
|
||||
/// keeps emitting log records while the cleanup hooks run, and every one of
|
||||
/// them would otherwise make the log bridge repaint the cleared bars from
|
||||
/// their cached frames.
|
||||
fn suspend_shared(shared: &Shared) {
|
||||
if !shared.enabled {
|
||||
return;
|
||||
}
|
||||
if shared.suspended.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
restore_tty_echo();
|
||||
shared.top.disable_steady_tick();
|
||||
drop_pane(shared);
|
||||
shared.top.finish_and_clear();
|
||||
shared.multi.set_draw_target(ProgressDrawTarget::hidden());
|
||||
}
|
||||
|
||||
// SAFETY: installing a signal handler; the handler itself is best-effort
|
||||
// (it performs non async-signal-safe operations, acceptable here because
|
||||
// it immediately exits afterwards).
|
||||
/// Termios snapshot taken when the echo is suppressed; `Some` only while the
|
||||
/// live view is on screen
|
||||
static SAVED_TTY_TERMIOS: Mutex<Option<libc::termios>> = Mutex::new(None);
|
||||
|
||||
/// Stop the tty from rendering control-character input (^C would show as a
|
||||
/// visible two-character "^C") while the live view is up: rendered echoes
|
||||
/// move the cursor without indicatif knowing, and the teardown erase ends
|
||||
/// up aimed past the widget
|
||||
///
|
||||
/// ECHO itself stays on — turning it off would trigger terminals'
|
||||
/// hidden-input padlock — so the only visible difference is that a ^C
|
||||
/// keypress echoes as a raw, cursor-invisible control byte. No-op without a
|
||||
/// tty on stdin.
|
||||
fn suppress_control_char_echo() {
|
||||
// SAFETY: tcgetattr on stdin with a valid, zero-initialized buffer
|
||||
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: reading the current attributes of stdin
|
||||
if unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut termios) } != 0 {
|
||||
return;
|
||||
}
|
||||
*SAVED_TTY_TERMIOS.lock().unwrap() = Some(termios);
|
||||
termios.c_lflag &= !libc::ECHOCTL;
|
||||
// SAFETY: applying the modified attributes to stdin
|
||||
unsafe {
|
||||
libc::signal(libc::SIGINT, on_sigint as *const () as usize);
|
||||
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the sigint handler at the current log file location
|
||||
fn update_sigint_log_path(log_path: &Path) {
|
||||
*SIGINT_LOG_PATH.lock().unwrap() = Some(log_path.to_path_buf());
|
||||
}
|
||||
|
||||
extern "C" fn on_sigint(_sig: libc::c_int) {
|
||||
// Best-effort cleanup: clear leftover widget lines and show the cursor
|
||||
let _ = execute!(
|
||||
std::io::stdout(),
|
||||
Clear(ClearType::FromCursorDown),
|
||||
cursor::Show
|
||||
);
|
||||
if let Ok(guard) = SIGINT_LOG_PATH.try_lock()
|
||||
&& let Some(path) = guard.as_ref()
|
||||
{
|
||||
eprintln!("\nInterrupted — full log: {}", path.display());
|
||||
}
|
||||
// Run the registered cleanup hooks (currently: unmount and remove the
|
||||
// ephemeral build chroot, see `deb::ephemeral::sigint_cleanup_chroot`),
|
||||
// then exit with the conventional 130 status.
|
||||
//
|
||||
// Like the terminal restoration above, this is NOT strictly
|
||||
// async-signal-safe: it locks a mutex, spawns subprocesses and does I/O.
|
||||
// That is a deliberate tradeoff, no worse than the rest of this handler:
|
||||
// exiting immediately would skip all destructors and leak the chroot
|
||||
// together with its bind-mounted /proc and overlay mounts. The hooks are
|
||||
// self-contained (they only touch stored paths and spawn umount/rm
|
||||
// directly), so they cannot deadlock on a lock the interrupted thread
|
||||
// might have held; the hook registry itself is only ever taken with
|
||||
// try_lock plus a bounded retry for the same reason. Note that SIGINT
|
||||
// stays blocked for the duration of the handler, so a second Ctrl-C will
|
||||
// not interrupt a slow cleanup — send SIGTERM/SIGKILL if it ever hangs.
|
||||
crate::deb::ephemeral::run_cleanup_hooks();
|
||||
// SAFETY: raw exit bypassing destructors, intended in a signal handler
|
||||
unsafe {
|
||||
libc::_exit(130);
|
||||
/// Restore the tty attributes saved by [`suppress_tty_echo`]
|
||||
fn restore_tty_echo() {
|
||||
if let Some(termios) = SAVED_TTY_TERMIOS.lock().unwrap().take() {
|
||||
// SAFETY: re-applying the snapshot taken at view start
|
||||
unsafe {
|
||||
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,6 +743,38 @@ mod tests {
|
||||
assert!(ensure_pane(&shared).is_none());
|
||||
}
|
||||
|
||||
/// The interrupt report kills the shared draw target: during the cleanup
|
||||
/// hooks the interrupted flow keeps emitting log records, and every one
|
||||
/// of them would otherwise make the log bridge repaint the bars from
|
||||
/// their cached frames — resurrecting the widget that was just cleared.
|
||||
#[test]
|
||||
fn interrupt_report_disables_further_redraws() {
|
||||
let multi = MultiProgress::new();
|
||||
let shared = Shared {
|
||||
multi: multi.clone(),
|
||||
top: multi.add(ProgressBar::new(0)),
|
||||
pane: Mutex::new(None),
|
||||
state: Mutex::new(Pipeline {
|
||||
classifier: Box::new(GenericClassifier::new()),
|
||||
lines: VecDeque::new(),
|
||||
errors: Vec::new(),
|
||||
last_draw: Instant::now(),
|
||||
bar_total: 0,
|
||||
}),
|
||||
tee: Mutex::new(None),
|
||||
log_path: Mutex::new(std::env::temp_dir().join("pkh-interrupt-report-test.log")),
|
||||
timestamp: String::new(),
|
||||
enabled: true,
|
||||
suspended: AtomicBool::new(false),
|
||||
started: Instant::now(),
|
||||
};
|
||||
|
||||
interrupt_report(&shared);
|
||||
|
||||
assert!(shared.suspended.load(Ordering::SeqCst));
|
||||
assert!(shared.multi.is_hidden());
|
||||
}
|
||||
|
||||
/// Best-effort ANSI escape stripper, enough for the assertions above
|
||||
fn strip_ansi(line: &str) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
Reference in New Issue
Block a user