//! 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 Option + Send>; /// A boxed, send-safe cleanup hook body type CleanupFn = Box; /// The reporter run before the cleanup hooks; taken out when it runs static REPORTER: Mutex> = 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> = 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 .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 { 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) { 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::() { 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 { 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) { 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); } }