interrupt: add the passive interrupt core

The library holds only the state its own types need when a Ctrl+C
arrives: the interrupted flag flows check to stand down, the cleanup
hook registry for resources that must not outlive the process (the
ephemeral build chroot), and the live view's reporter slot. The
signal handling itself is CLI wiring and lands separately: nothing
here installs handlers, prints or exits, so a library consumer
embedding these types keeps its own signal disposition.
This commit is contained in:
2026-09-22 10:43:49 +02:00
parent 8250a0e3b1
commit 84405a6762
3 changed files with 315 additions and 0 deletions
+2
View File
@@ -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
+310
View File
@@ -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<dyn FnOnce() -> Option<String> + Send>;
/// A boxed, send-safe cleanup hook body
type CleanupFn = Box<dyn Fn() + Send>;
/// The reporter run before the cleanup hooks; taken out when it runs
static REPORTER: Mutex<Option<Reporter>> = 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<Vec<CleanupHook>> = 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> {
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<CleanupHook> {
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<CleanupHook>) {
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::<String>() {
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<CleanupHook> {
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<AtomicUsize>) {
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);
}
}
+3
View File
@@ -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`)