diff --git a/AGENTS.md b/AGENTS.md index d7202f2..7ae3b3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/src/interrupt.rs b/src/interrupt.rs new file mode 100644 index 0000000..d45c3fd --- /dev/null +++ b/src/interrupt.rs @@ -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 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5aa8cad..44cf391 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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`)