cli: intercept Ctrl+C for deb, build and put

The SIGINT handler only records the interruption and wakes a watchdog
through a self-pipe (async-signal-safe); the watchdog runs the whole
shutdown in thread context — the live view's reporter first, then the
notice and the log hint, then the cleanup hooks, then exit 130. Flows
park in wait_for_shutdown instead of racing it with their own exit,
and an end-to-end test drives the sequence by re-spawning the test
binary and raising SIGINT at itself.
This commit is contained in:
2026-09-22 10:44:05 +02:00
parent adde0ee977
commit 5a1c1672cd
+226
View File
@@ -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}"
);
}
}