diff --git a/src/main.rs b/src/main.rs index 7aabb16..dfac489 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::("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::("ppa").map(|s| s.as_str()); let changes = sub_matches .get_one::("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::("series").cloned(); let pocket = sub_matches.get_one::("pocket").cloned(); let arch = sub_matches.get_one::("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}" + ); + } +}