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:
+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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user